DEV: Split the Query Listing and Query Editing code (#356)

The code for listing all of the defined queries is mixed together with the code for editing a single query. Notably, this results in large amounts of unnecessary data being loaded for the list view, which causes substantial rendering slowdowns.

To address this issue, we now only load the necessary data for the list view, and load the full data when it's actually needed (any endpoint that returns a single query). The primary changes that achieve this are:

- Create a new `QueryDetailsSerializer` serialiser, which includes all of the query info, and change the existing `QuerySerializer` serialiser to only include the necessary attributes of each query for generating a list of them all.
- Split the monolith `/plugins/explorer` route into `/plugins/explorer` for showing just the list of queries, and `/plugins/explorer/queries/:query_id`, for showing/editing/running a specific query.
This commit is contained in:
Gary Pendergast 2025-02-10 14:54:01 +11:00 committed by GitHub
parent bd6263e9b8
commit d726c4889e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 837 additions and 667 deletions

View File

@ -40,7 +40,7 @@ module ::DiscourseDataExplorer
end
return raise Discourse::NotFound if !guardian.user_can_access_query?(@query) || @query.hidden
render_serialized @query, QuerySerializer, root: "query"
render_serialized @query, QueryDetailsSerializer, root: "query"
end
def groups
@ -68,7 +68,7 @@ module ::DiscourseDataExplorer
query_group = QueryGroup.find_by(query_id: @query.id, group_id: @group.id)
render json: {
query: serialize_data(@query, QuerySerializer, root: nil),
query: serialize_data(@query, QueryDetailsSerializer, root: nil),
query_group: serialize_data(query_group, QueryGroupSerializer, root: nil),
}
end
@ -93,7 +93,7 @@ module ::DiscourseDataExplorer
)
group_ids = params.require(:query)[:group_ids]
group_ids&.each { |group_id| query.query_groups.find_or_create_by!(group_id: group_id) }
render_serialized query, QuerySerializer, root: "query"
render_serialized query, QueryDetailsSerializer, root: "query"
end
def update
@ -107,7 +107,7 @@ module ::DiscourseDataExplorer
group_ids&.each { |group_id| @query.query_groups.find_or_create_by!(group_id: group_id) }
end
render_serialized @query, QuerySerializer, root: "query"
render_serialized @query, QueryDetailsSerializer, root: "query"
rescue ValidationError => e
render_json_error e.message
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
module ::DiscourseDataExplorer
class QueryDetailsSerializer < QuerySerializer
attributes :sql, :param_info, :created_at, :hidden
def param_info
object&.params&.uniq { |p| p.identifier }&.map(&:to_hash)
end
end
end

View File

@ -2,21 +2,7 @@
module ::DiscourseDataExplorer
class QuerySerializer < ActiveModel::Serializer
attributes :id,
:sql,
:name,
:description,
:param_info,
:created_at,
:username,
:group_ids,
:last_run_at,
:hidden,
:user_id
def param_info
object&.params&.uniq { |p| p.identifier }&.map(&:to_hash)
end
attributes :id, :name, :description, :username, :group_ids, :last_run_at, :user_id
def username
object&.user&.username

View File

@ -0,0 +1,192 @@
import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { Promise } from "rsvp";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
export default class PluginsExplorerController extends Controller {
@service dialog;
@service appEvents;
@service router;
@tracked sortByProperty = "last_run_at";
@tracked sortDescending = true;
@tracked params;
@tracked search;
@tracked newQueryName;
@tracked showCreate;
@tracked loading = false;
queryParams = ["id"];
explain = false;
acceptedImportFileTypes = ["application/json"];
order = null;
form = null;
get sortedQueries() {
const sortedQueries = this.model.sortBy(this.sortByProperty);
return this.sortDescending ? sortedQueries.reverse() : sortedQueries;
}
get parsedParams() {
return this.params ? JSON.parse(this.params) : null;
}
get filteredContent() {
const regexp = new RegExp(this.search, "i");
return this.sortedQueries.filter(
(result) => regexp.test(result.name) || regexp.test(result.description)
);
}
get createDisabled() {
return (this.newQueryName || "").trim().length === 0;
}
addCreatedRecord(record) {
this.model.pushObject(record);
this.router.transitionTo(
"adminPlugins.explorer.queries.details",
record.id
);
}
async _importQuery(file) {
const json = await this._readFileAsTextAsync(file);
const query = this._parseQuery(json);
const record = this.store.createRecord("query", query);
const response = await record.save();
return response.target;
}
_parseQuery(json) {
const parsed = JSON.parse(json);
const query = parsed.query;
if (!query || !query.sql) {
throw new TypeError();
}
query.id = 0; // 0 means no Id yet
return query;
}
_readFileAsTextAsync(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
reader.readAsText(file);
});
}
@bind
dragMove(e) {
if (!e.movementY && !e.movementX) {
return;
}
const editPane = document.querySelector(".query-editor");
const target = editPane.querySelector(".panels-flex");
const grippie = editPane.querySelector(".grippie");
// we need to get the initial height / width of edit pane
// before we manipulate the size
if (!this.initialPaneWidth && !this.originalPaneHeight) {
this.originalPaneWidth = target.clientWidth;
this.originalPaneHeight = target.clientHeight;
}
const newHeight = Math.max(
this.originalPaneHeight,
target.clientHeight + e.movementY
);
const newWidth = Math.max(
this.originalPaneWidth,
target.clientWidth + e.movementX
);
target.style.height = newHeight + "px";
target.style.width = newWidth + "px";
grippie.style.width = newWidth + "px";
this.appEvents.trigger("ace:resize");
}
@bind
scrollTop() {
window.scrollTo(0, 0);
}
@action
async import(files) {
try {
this.loading = true;
const file = files[0];
const record = await this._importQuery(file);
this.addCreatedRecord(record);
} catch (e) {
if (e.jqXHR) {
popupAjaxError(e);
} else if (e instanceof SyntaxError) {
this.dialog.alert(i18n("explorer.import.unparseable_json"));
} else if (e instanceof TypeError) {
this.dialog.alert(i18n("explorer.import.wrong_json"));
} else {
this.dialog.alert(i18n("errors.desc.unknown"));
// eslint-disable-next-line no-console
console.error(e);
}
} finally {
this.loading = false;
}
}
@action
displayCreate() {
this.showCreate = true;
}
@action
resetParams() {
this.selectedItem.resetParams();
}
@action
updateSortProperty(property) {
if (this.sortByProperty === property) {
this.sortDescending = !this.sortDescending;
} else {
this.sortByProperty = property;
this.sortDescending = true;
}
}
@action
async create() {
try {
const name = this.newQueryName.trim();
this.loading = true;
this.showCreate = false;
const result = await this.store.createRecord("query", { name }).save();
this.addCreatedRecord(result.target);
} catch (error) {
popupAjaxError(error);
} finally {
this.loading = false;
}
}
@action
updateSearch(value) {
this.search = value;
}
@action
updateNewQueryName(value) {
this.newQueryName = value;
}
}

View File

@ -6,44 +6,29 @@ import { Promise } from "rsvp";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
import QueryHelp from "discourse/plugins/discourse-data-explorer/discourse/components/modal/query-help";
import { ParamValidationError } from "discourse/plugins/discourse-data-explorer/discourse/components/param-input-form";
import Query from "discourse/plugins/discourse-data-explorer/discourse/models/query";
const NoQuery = Query.create({ name: "No queries", fake: true, group_ids: [] });
export default class PluginsExplorerController extends Controller {
@service modal;
@service dialog;
@service appEvents;
@service router;
@tracked sortByProperty = "last_run_at";
@tracked sortDescending = true;
@tracked params;
@tracked search;
@tracked newQueryName;
@tracked showCreate;
@tracked editingName = false;
@tracked editingQuery = false;
@tracked selectedQueryId;
@tracked loading = false;
@tracked showResults = false;
@tracked hideSchema = false;
@tracked results = this.selectedItem.results;
@tracked results = this.model.results;
@tracked dirty = false;
queryParams = ["params", { selectedQueryId: "id" }];
queryParams = ["params"];
explain = false;
acceptedImportFileTypes = ["application/json"];
order = null;
form = null;
get validQueryPresent() {
return !!this.selectedItem.id;
}
get saveDisabled() {
return !this.dirty;
}
@ -52,33 +37,12 @@ export default class PluginsExplorerController extends Controller {
return this.dirty;
}
get sortedQueries() {
const sortedQueries = this.model.sortBy(this.sortByProperty);
return this.sortDescending ? sortedQueries.reverse() : sortedQueries;
}
get parsedParams() {
return this.params ? JSON.parse(this.params) : null;
}
get filteredContent() {
const regexp = new RegExp(this.search, "i");
return this.sortedQueries.filter(
(result) => regexp.test(result.name) || regexp.test(result.description)
);
}
get createDisabled() {
return (this.newQueryName || "").trim().length === 0;
}
get selectedItem() {
const query = this.model.findBy("id", parseInt(this.selectedQueryId, 10));
return query || NoQuery;
}
get editDisabled() {
return parseInt(this.selectedQueryId, 10) < 0 ? true : false;
return parseInt(this.model.id, 10) < 0 ? true : false;
}
get groupOptions() {
@ -89,27 +53,11 @@ export default class PluginsExplorerController extends Controller {
});
}
get othersDirty() {
return !!this.model.find((q) => q !== this.selectedItem && this.dirty);
}
addCreatedRecord(record) {
this.model.pushObject(record);
this.selectedQueryId = record.id;
this.dirty = false;
this.setProperties({
showResults: false,
results: null,
editingName: true,
editingQuery: true,
});
}
@action
async save() {
try {
this.loading = true;
await this.selectedItem.save();
await this.model.save();
this.dirty = false;
this.editingName = false;
@ -194,17 +142,10 @@ export default class PluginsExplorerController extends Controller {
@bind
didEndDrag() {}
@bind
scrollTop() {
window.scrollTo(0, 0);
this.editingName = false;
this.editingQuery = false;
}
@action
updateGroupIds(value) {
this.dirty = true;
this.selectedItem.set("group_ids", value);
this.model.set("group_ids", value);
}
@action
@ -212,36 +153,6 @@ export default class PluginsExplorerController extends Controller {
this.hideSchema = value;
}
@action
async import(files) {
try {
this.loading = true;
const file = files[0];
const record = await this._importQuery(file);
this.addCreatedRecord(record);
} catch (e) {
if (e.jqXHR) {
popupAjaxError(e);
} else if (e instanceof SyntaxError) {
this.dialog.alert(i18n("explorer.import.unparseable_json"));
} else if (e instanceof TypeError) {
this.dialog.alert(i18n("explorer.import.wrong_json"));
} else {
this.dialog.alert(i18n("errors.desc.unknown"));
// eslint-disable-next-line no-console
console.error(e);
}
} finally {
this.loading = false;
this.dirty = true;
}
}
@action
displayCreate() {
this.showCreate = true;
}
@action
editName() {
this.editingName = true;
@ -254,18 +165,12 @@ export default class PluginsExplorerController extends Controller {
@action
download() {
window.open(this.selectedItem.downloadUrl, "_blank");
window.open(this.model.downloadUrl, "_blank");
}
@action
goHome() {
this.order = null;
this.showResults = false;
this.selectedQueryId = null;
this.params = null;
this.sortByProperty = "last_run_at";
this.sortDescending = true;
this.router.transitionTo({ queryParams: { id: null, params: null } });
this.router.transitionTo("adminPlugins.explorer");
}
@action
@ -275,48 +180,17 @@ export default class PluginsExplorerController extends Controller {
@action
resetParams() {
this.selectedItem.resetParams();
}
@action
updateSortProperty(property) {
if (this.sortByProperty === property) {
this.sortDescending = !this.sortDescending;
} else {
this.sortByProperty = property;
this.sortDescending = true;
}
}
@action
async create() {
try {
const name = this.newQueryName.trim();
this.loading = true;
this.showCreate = false;
const result = await this.store.createRecord("query", { name }).save();
this.addCreatedRecord(result.target);
} catch (error) {
popupAjaxError(error);
} finally {
this.loading = false;
this.dirty = true;
}
this.model.resetParams();
}
@action
async discard() {
try {
this.loading = true;
const result = await this.store.find("query", this.selectedItem.id);
this.selectedItem.setProperties(
result.getProperties(Query.updatePropertyNames)
);
if (
!this.selectedItem.group_ids ||
!Array.isArray(this.selectedItem.group_ids)
) {
this.selectedItem.set("group_ids", []);
const result = await this.store.find("query", this.model.id);
this.model.setProperties(result.getProperties(Query.updatePropertyNames));
if (!this.model.group_ids || !Array.isArray(this.model.group_ids)) {
this.model.set("group_ids", []);
}
this.dirty = false;
} catch (error) {
@ -331,8 +205,8 @@ export default class PluginsExplorerController extends Controller {
try {
this.loading = true;
this.showResults = false;
await this.store.destroyRecord("query", this.selectedItem);
this.selectedItem.set("destroyed", true);
await this.store.destroyRecord("query", this.model);
this.model.set("destroyed", true);
} catch (error) {
popupAjaxError(error);
} finally {
@ -345,8 +219,8 @@ export default class PluginsExplorerController extends Controller {
try {
this.loading = true;
this.showResults = true;
await this.selectedItem.save();
this.selectedItem.set("destroyed", false);
await this.model.save();
this.model.set("destroyed", false);
} catch (error) {
popupAjaxError(error);
} finally {
@ -359,16 +233,6 @@ export default class PluginsExplorerController extends Controller {
this.form = form;
}
@action
updateSearch(value) {
this.search = value;
}
@action
updateNewQueryName(value) {
this.newQueryName = value;
}
@action
setDirty() {
this.dirty = true;
@ -382,7 +246,7 @@ export default class PluginsExplorerController extends Controller {
@action
async run() {
let params = null;
if (this.selectedItem.hasParams) {
if (this.model.hasParams) {
try {
params = await this.form?.submit();
} catch (err) {
@ -400,7 +264,7 @@ export default class PluginsExplorerController extends Controller {
params: JSON.stringify(params),
});
ajax("/admin/plugins/explorer/queries/" + this.selectedItem.id + "/run", {
ajax("/admin/plugins/explorer/queries/" + this.model.id + "/run", {
type: "POST",
data: {
params: JSON.stringify(params),

View File

@ -2,6 +2,10 @@ export default {
resource: "admin.adminPlugins",
path: "/plugins",
map() {
this.route("explorer");
this.route("explorer", function () {
this.route("queries", function () {
this.route("details", { path: "/:query_id" });
});
});
},
};

View File

@ -24,18 +24,18 @@ export default class Query extends RestModel {
return getURL(`/admin/plugins/explorer/queries/${this.id}.json?export=1`);
}
@computed("param_info", "updateing")
@computed("param_info", "updating")
get hasParams() {
// When saving, we need to refresh the param-input component to clean up the old key
return this.param_info.length && !this.updateing;
return this.param_info.length && !this.updating;
}
beforeUpdate() {
this.set("updateing", true);
this.set("updating", true);
}
afterUpdate() {
this.set("updateing", false);
this.set("updating", false);
}
resetParams() {

View File

@ -0,0 +1,47 @@
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";
export default class AdminPluginsExplorerIndex extends DiscourseRoute {
@service router;
beforeModel(transition) {
// Redirect old /explorer?id=123 route to /explorer/queries/123
if (transition.to.queryParams.id) {
this.router.transitionTo(
"adminPlugins.explorer.queries.details",
transition.to.queryParams.id
);
}
}
model() {
if (!this.currentUser.admin) {
// display "Only available to admins" message
return { model: null, schema: null, disallow: true, groups: null };
}
const groupPromise = ajax("/admin/plugins/explorer/groups.json");
const queryPromise = this.store.findAll("query");
return groupPromise.then((groups) => {
let groupNames = {};
groups.forEach((g) => {
groupNames[g.id] = g.name;
});
return queryPromise.then((model) => {
model.forEach((query) => {
query.set(
"group_names",
(query.group_ids || []).map((id) => groupNames[id])
);
});
return { model, groups };
});
});
}
setupController(controller, model) {
controller.setProperties(model);
}
}

View File

@ -1,8 +1,8 @@
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";
export default class AdminPluginsExplorer extends DiscourseRoute {
model() {
export default class AdminPluginsExplorerQueriesDetails extends DiscourseRoute {
model(params) {
if (!this.currentUser.admin) {
// display "Only available to admins" message
return { model: null, schema: null, disallow: true, groups: null };
@ -12,7 +12,7 @@ export default class AdminPluginsExplorer extends DiscourseRoute {
const schemaPromise = ajax("/admin/plugins/explorer/schema.json", {
cache: true,
});
const queryPromise = this.store.findAll("query");
const queryPromise = this.store.find("query", params.query_id);
return groupPromise.then((groups) => {
let groupNames = {};
@ -21,12 +21,10 @@ export default class AdminPluginsExplorer extends DiscourseRoute {
});
return schemaPromise.then((schema) => {
return queryPromise.then((model) => {
model.forEach((query) => {
query.set(
"group_names",
(query.group_ids || []).map((id) => groupNames[id])
);
});
model.set(
"group_names",
(model.group_ids || []).map((id) => groupNames[id])
);
return { model, schema, groups };
});
});

View File

@ -0,0 +1,166 @@
{{#if this.disallow}}
<h1>{{i18n "explorer.admins_only"}}</h1>
{{else}}
<div class="query-list">
<TextField
@value={{this.search}}
@placeholderKey="explorer.search_placeholder"
@onChange={{this.updateSearch}}
/>
<DButton
@action={{this.displayCreate}}
@icon="plus"
class="no-text btn-right"
/>
<PickFilesButton
@label="explorer.import.label"
@icon="upload"
@acceptedFormatsOverride={{this.acceptedImportFileTypes}}
@showButton="true"
@onFilesPicked={{this.import}}
class="import-btn"
/>
</div>
{{#if this.showCreate}}
<div class="query-create">
<TextField
@value={{this.newQueryName}}
@placeholderKey="explorer.create_placeholder"
@onChange={{this.updateNewQueryName}}
/>
<DButton
@action={{this.create}}
@disabled={{this.createDisabled}}
@label="explorer.create"
@icon="plus"
/>
</div>
{{/if}}
{{#if this.othersDirty}}
<div class="warning">
{{d-icon "triangle-exclamation"}}
{{i18n "explorer.others_dirty"}}
</div>
{{/if}}
{{#if this.model.length}}
<ConditionalLoadingSpinner @condition={{this.loading}} />
<div class="container">
<table class="d-admin-table recent-queries">
<thead class="heading-container">
<th class="col heading name">
<div
role="button"
class="heading-toggle"
{{on "click" (fn this.updateSortProperty "name")}}
>
<TableHeaderToggle
@field="name"
@labelKey="explorer.query_name"
@order={{this.order}}
@asc={{not this.sortDescending}}
@automatic="true"
/>
</div>
</th>
<th class="col heading created-by">
<div
role="button"
class="heading-toggle"
{{on "click" (fn this.updateSortProperty "username")}}
>
<TableHeaderToggle
@field="username"
@labelKey="explorer.query_user"
@order={{this.order}}
@asc={{not this.sortDescending}}
@automatic="true"
/>
</div>
</th>
<th class="col heading group-names">
<div class="group-names-header">
{{i18n "explorer.query_groups"}}
</div>
</th>
<th class="col heading created-at">
<div
role="button"
class="heading-toggle"
{{on "click" (fn this.updateSortProperty "last_run_at")}}
>
<TableHeaderToggle
@field="last_run_at"
@labelKey="explorer.query_time"
@order={{this.order}}
@asc={{not this.sortDescending}}
@automatic="true"
/>
</div>
</th>
</thead>
<tbody>
{{#each this.filteredContent as |query|}}
<tr class="d-admin-row__content query-row">
<td class="d-admin-row__overview">
<a
{{on "click" this.scrollTop}}
href="/admin/plugins/explorer/queries/{{query.id}}"
>
<b class="query-name">{{query.name}}</b>
<medium class="query-desc">{{query.description}}</medium>
</a>
</td>
<td class="d-admin-row__detail query-created-by">
<div class="d-admin-row__mobile-label">
{{i18n "explorer.query_user"}}
</div>
{{#if query.username}}
<div>
<a href="/u/{{query.username}}/activity">
<medium>{{query.username}}</medium>
</a>
</div>
{{/if}}
</td>
<td class="d-admin-row__detail query-group-names">
<div class="d-admin-row__mobile-label">
{{i18n "explorer.query_groups"}}
</div>
<div class="group-names">
{{#each query.group_names as |group|}}
<ShareReport @group={{group}} @query={{query}} />
{{/each}}
</div>
</td>
<td class="d-admin-row__detail query-created-at">
<div class="d-admin-row__mobile-label">
{{i18n "explorer.query_time"}}
</div>
{{#if query.last_run_at}}
<medium>
{{bound-date query.last_run_at}}
</medium>
{{else if query.created_at}}
<medium>
{{bound-date query.created_at}}
</medium>
{{/if}}
</td>
</tr>
{{else}}
<br />
<em class="no-search-results">
{{i18n "explorer.no_search_results"}}
</em>
{{/each}}
</tbody>
</table>
</div>
<div class="explorer-pad-bottom"></div>
{{/if}}
{{/if}}

View File

@ -0,0 +1,223 @@
{{#if this.disallow}}
<h1>{{i18n "explorer.admins_only"}}</h1>
{{else}}
<div class="query-edit {{if this.editName 'editing'}}">
{{#if this.editingName}}
<div class="name">
<DButton
@action={{this.goHome}}
@icon="chevron-left"
class="previous"
/>
<DButton @action={{this.exitEdit}} @icon="xmark" class="previous" />
<div class="name-text-field">
<TextField @value={{this.model.name}} @onChange={{this.setDirty}} />
</div>
</div>
<div class="desc">
<DTextarea
@value={{this.model.description}}
@placeholder={{i18n "explorer.description_placeholder"}}
@input={{this.setDirty}}
/>
</div>
{{else}}
<div class="name">
<DButton
@action={{this.goHome}}
@icon="chevron-left"
class="previous"
/>
<h1>
{{this.model.name}}
{{#unless this.editDisabled}}
<a href {{action "editName"}} class="edit-query-name">
{{d-icon "pencil"}}
</a>
{{/unless}}
</h1>
</div>
<div class="desc">
{{this.model.description}}
</div>
{{/if}}
{{#unless this.model.destroyed}}
<div class="groups">
<span class="label">{{i18n "explorer.allow_groups"}}</span>
<span>
<MultiSelect
@value={{this.model.group_ids}}
@content={{this.groupOptions}}
@options={{hash allowAny=false}}
@onChange={{this.updateGroupIds}}
/>
</span>
</div>
{{/unless}}
<div class="clear"></div>
{{#if this.editingQuery}}
<div class="query-editor {{if this.hideSchema 'no-schema'}}">
<div class="panels-flex">
<div class="editor-panel">
<AceEditor
{{on "click" this.setDirty}}
@content={{this.model.sql}}
@onChange={{fn (mut this.model.sql)}}
@mode="sql"
@disabled={{this.model.destroyed}}
@save={{this.save}}
@submit={{this.saveAndRun}}
/>
</div>
<div class="right-panel">
<ExplorerSchema
@schema={{this.schema}}
@hideSchema={{this.hideSchema}}
@updateHideSchema={{this.updateHideSchema}}
/>
</div>
</div>
<div
class="grippie"
{{draggable
didStartDrag=this.didStartDrag
didEndDrag=this.didEndDrag
dragMove=this.dragMove
}}
>
{{d-icon "discourse-expand"}}
</div>
<div class="clear"></div>
</div>
{{else}}
<div class="sql">
<CodeView
@value={{this.model.sql}}
@codeClass="sql"
@setDirty={{this.setDirty}}
/>
</div>
{{/if}}
<div class="clear"></div>
<div class="pull-left left-buttons">
{{#if this.editingQuery}}
<DButton
class="btn-save-query"
@action={{this.save}}
@label="explorer.save"
@disabled={{this.saveDisabled}}
/>
{{else}}
{{#unless this.editDisabled}}
<DButton
class="btn-edit-query"
@action={{this.editQuery}}
@label="explorer.edit"
@icon="pencil"
/>
{{/unless}}
{{/if}}
<DButton
@action={{this.download}}
@label="explorer.export"
@disabled={{this.runDisabled}}
@icon="download"
/>
{{#if this.editingQuery}}
<DButton
@action={{this.showHelpModal}}
@label="explorer.help.label"
@icon="circle-question"
/>
{{/if}}
</div>
<div class="pull-right right-buttons">
{{#if this.model.destroyed}}
<DButton
@action={{this.recover}}
@icon="arrow-rotate-left"
@label="explorer.recover"
/>
{{else}}
{{#if this.editingQuery}}
<DButton
@action={{this.discard}}
@icon="arrow-rotate-left"
@label="explorer.undo"
@disabled={{this.saveDisabled}}
/>
{{/if}}
<DButton
@action={{this.destroyQuery}}
@icon="trash-can"
@label="explorer.delete"
class="btn-danger"
/>
{{/if}}
</div>
<div class="clear"></div>
</div>
<form class="query-run" {{on "submit" this.run}}>
{{#if this.model.hasParams}}
<ParamInputForm
@initialValues={{this.parsedParams}}
@paramInfo={{this.model.param_info}}
@onRegisterApi={{this.onRegisterApi}}
/>
{{/if}}
{{#if this.runDisabled}}
{{#if this.saveDisabled}}
<DButton @label="explorer.run" @disabled="true" class="btn-primary" />
{{else}}
<DButton
@action={{this.saveAndRun}}
@icon="play"
@label="explorer.saverun"
class="btn-primary"
/>
{{/if}}
{{else}}
<DButton
@action={{this.run}}
@icon="play"
@label="explorer.run"
@disabled={{this.runDisabled}}
@type="submit"
class="btn-primary"
/>
{{/if}}
<label class="query-plan">
<Input @type="checkbox" @checked={{this.explain}} name="explain" />
{{i18n "explorer.explain_label"}}
</label>
</form>
<hr />
<ConditionalLoadingSpinner @condition={{this.loading}} />
<QueryResultsWrapper
@results={{this.results}}
@showResults={{this.showResults}}
@query={{this.selectedItem}}
@content={{this.results}}
/>
{{/if}}

View File

@ -1,405 +0,0 @@
{{#if this.disallow}}
<h1>{{i18n "explorer.admins_only"}}</h1>
{{else}}
{{#unless this.validQueryPresent}}
<div class="query-list">
<TextField
@value={{this.search}}
@placeholderKey="explorer.search_placeholder"
@onChange={{this.updateSearch}}
/>
<DButton
@action={{this.displayCreate}}
@icon="plus"
class="no-text btn-right"
/>
<PickFilesButton
@label="explorer.import.label"
@icon="upload"
@acceptedFormatsOverride={{this.acceptedImportFileTypes}}
@showButton="true"
@onFilesPicked={{this.import}}
class="import-btn"
/>
</div>
{{#if this.showCreate}}
<div class="query-create">
<TextField
@value={{this.newQueryName}}
@placeholderKey="explorer.create_placeholder"
@onChange={{this.updateNewQueryName}}
/>
<DButton
@action={{this.create}}
@disabled={{this.createDisabled}}
@label="explorer.create"
@icon="plus"
/>
</div>
{{/if}}
{{#if this.othersDirty}}
<div class="warning">
{{d-icon "triangle-exclamation"}}
{{i18n "explorer.others_dirty"}}
</div>
{{/if}}
{{/unless}}
{{#if this.model.length}}
{{#unless this.selectedItem.fake}}
<div class="query-edit {{if this.editName 'editing'}}">
{{#if this.selectedItem}}
{{#if this.editingName}}
<div class="name">
<DButton
@action={{this.goHome}}
@icon="chevron-left"
class="previous"
/>
<DButton
@action={{this.exitEdit}}
@icon="xmark"
class="previous"
/>
<div class="name-text-field">
<TextField
@value={{this.selectedItem.name}}
@onChange={{this.setDirty}}
/>
</div>
</div>
<div class="desc">
<DTextarea
@value={{this.selectedItem.description}}
@placeholder={{i18n "explorer.description_placeholder"}}
@input={{this.setDirty}}
/>
</div>
{{else}}
<div class="name">
<DButton
@action={{this.goHome}}
@icon="chevron-left"
class="previous"
/>
<h1>
{{this.selectedItem.name}}
{{#unless this.editDisabled}}
<a href {{action "editName"}} class="edit-query-name">
{{d-icon "pencil"}}
</a>
{{/unless}}
</h1>
</div>
<div class="desc">
{{this.selectedItem.description}}
</div>
{{/if}}
{{#unless this.selectedItem.destroyed}}
<div class="groups">
<span class="label">{{i18n "explorer.allow_groups"}}</span>
<span>
<MultiSelect
@value={{this.selectedItem.group_ids}}
@content={{this.groupOptions}}
@options={{hash allowAny=false}}
@onChange={{this.updateGroupIds}}
/>
</span>
</div>
{{/unless}}
<div class="clear"></div>
{{#if this.editingQuery}}
<div class="query-editor {{if this.hideSchema 'no-schema'}}">
<div class="panels-flex">
<div class="editor-panel">
<AceEditor
{{on "click" this.setDirty}}
@content={{this.selectedItem.sql}}
@onChange={{fn (mut this.selectedItem.sql)}}
@mode="sql"
@disabled={{this.selectedItem.destroyed}}
@save={{this.save}}
@submit={{this.saveAndRun}}
/>
</div>
<div class="right-panel">
<ExplorerSchema
@schema={{this.schema}}
@hideSchema={{this.hideSchema}}
@updateHideSchema={{this.updateHideSchema}}
/>
</div>
</div>
<div
class="grippie"
{{draggable
didStartDrag=this.didStartDrag
didEndDrag=this.didEndDrag
dragMove=this.dragMove
}}
>
{{d-icon "discourse-expand"}}
</div>
<div class="clear"></div>
</div>
{{else}}
<div class="sql">
<CodeView
@value={{this.selectedItem.sql}}
@codeClass="sql"
@setDirty={{this.setDirty}}
/>
</div>
{{/if}}
<div class="clear"></div>
<div class="pull-left left-buttons">
{{#if this.editingQuery}}
<DButton
class="btn-save-query"
@action={{this.save}}
@label="explorer.save"
@disabled={{this.saveDisabled}}
/>
{{else}}
{{#unless this.editDisabled}}
<DButton
class="btn-edit-query"
@action={{this.editQuery}}
@label="explorer.edit"
@icon="pencil"
/>
{{/unless}}
{{/if}}
<DButton
@action={{this.download}}
@label="explorer.export"
@disabled={{this.runDisabled}}
@icon="download"
/>
{{#if this.editingQuery}}
<DButton
@action={{this.showHelpModal}}
@label="explorer.help.label"
@icon="circle-question"
/>
{{/if}}
</div>
<div class="pull-right right-buttons">
{{#if this.selectedItem.destroyed}}
<DButton
@action={{this.recover}}
@icon="arrow-rotate-left"
@label="explorer.recover"
/>
{{else}}
{{#if this.editingQuery}}
<DButton
@action={{this.discard}}
@icon="arrow-rotate-left"
@label="explorer.undo"
@disabled={{this.saveDisabled}}
/>
{{/if}}
<DButton
@action={{this.destroyQuery}}
@icon="trash-can"
@label="explorer.delete"
class="btn-danger"
/>
{{/if}}
</div>
<div class="clear"></div>
{{/if}}
</div>
<form class="query-run" {{on "submit" this.run}}>
{{#if this.selectedItem.hasParams}}
<ParamInputForm
@initialValues={{this.parsedParams}}
@paramInfo={{this.selectedItem.param_info}}
@onRegisterApi={{this.onRegisterApi}}
/>
{{/if}}
{{#if this.runDisabled}}
{{#if this.saveDisabled}}
<DButton
@label="explorer.run"
@disabled="true"
class="btn-primary"
/>
{{else}}
<DButton
@action={{this.saveAndRun}}
@icon="play"
@label="explorer.saverun"
class="btn-primary"
/>
{{/if}}
{{else}}
<DButton
@action={{this.run}}
@icon="play"
@label="explorer.run"
@disabled={{this.runDisabled}}
@type="submit"
class="btn-primary"
/>
{{/if}}
<label class="query-plan">
<Input @type="checkbox" @checked={{this.explain}} name="explain" />
{{i18n "explorer.explain_label"}}
</label>
</form>
<hr />
{{/unless}}
<ConditionalLoadingSpinner @condition={{this.loading}} />
{{#unless this.selectedItem.fake}}
<QueryResultsWrapper
@results={{this.results}}
@showResults={{this.showResults}}
@query={{this.selectedItem}}
@content={{this.results}}
/>
{{/unless}}
{{#unless this.validQueryPresent}}
<div class="container">
<table class="d-admin-table recent-queries">
<thead class="heading-container">
<th class="col heading name">
<div
role="button"
class="heading-toggle"
{{on "click" (fn this.updateSortProperty "name")}}
>
<TableHeaderToggle
@field="name"
@labelKey="explorer.query_name"
@order={{this.order}}
@asc={{not this.sortDescending}}
@automatic="true"
/>
</div>
</th>
<th class="col heading created-by">
<div
role="button"
class="heading-toggle"
{{on "click" (fn this.updateSortProperty "username")}}
>
<TableHeaderToggle
@field="username"
@labelKey="explorer.query_user"
@order={{this.order}}
@asc={{not this.sortDescending}}
@automatic="true"
/>
</div>
</th>
<th class="col heading group-names">
<div class="group-names-header">
{{i18n "explorer.query_groups"}}
</div>
</th>
<th class="col heading created-at">
<div
role="button"
class="heading-toggle"
{{on "click" (fn this.updateSortProperty "last_run_at")}}
>
<TableHeaderToggle
@field="last_run_at"
@labelKey="explorer.query_time"
@order={{this.order}}
@asc={{not this.sortDescending}}
@automatic="true"
/>
</div>
</th>
</thead>
<tbody>
{{#each this.filteredContent as |query|}}
<tr class="d-admin-row__content query-row">
<td class="d-admin-row__overview">
<a
{{on "click" this.scrollTop}}
href="/admin/plugins/explorer/?id={{query.id}}"
>
<b class="query-name">{{query.name}}</b>
<medium class="query-desc">{{query.description}}</medium>
</a>
</td>
<td class="d-admin-row__detail query-created-by">
<div class="d-admin-row__mobile-label">
{{i18n "explorer.query_user"}}
</div>
{{#if query.username}}
<div>
<a href="/u/{{query.username}}/activity">
<medium>{{query.username}}</medium>
</a>
</div>
{{/if}}
</td>
<td class="d-admin-row__detail query-group-names">
<div class="d-admin-row__mobile-label">
{{i18n "explorer.query_groups"}}
</div>
<div class="group-names">
{{#each query.group_names as |group|}}
<ShareReport @group={{group}} @query={{query}} />
{{/each}}
</div>
</td>
<td class="d-admin-row__detail query-created-at">
<div class="d-admin-row__mobile-label">
{{i18n "explorer.query_time"}}
</div>
{{#if query.last_run_at}}
<medium>
{{bound-date query.last_run_at}}
</medium>
{{else if query.created_at}}
<medium>
{{bound-date query.created_at}}
</medium>
{{/if}}
</td>
</tr>
{{else}}
<br />
<em class="no-search-results">
{{i18n "explorer.no_search_results"}}
</em>
{{/each}}
</tbody>
</table>
</div>
{{/unless}}
<div class="explorer-pad-bottom"></div>
{{/if}}
{{/if}}

View File

@ -23,7 +23,7 @@ en:
Here are the results:
%{table}
<a href='%{base_url}/admin/plugins/explorer?id=%{query_id}'>View query in Data Explorer</a>
<a href='%{base_url}/admin/plugins/explorer/queries/%{query_id}'>View query in Data Explorer</a>
Report created at %{created_at} (%{timezone})
post:
@ -33,7 +33,7 @@ en:
Here are the results:
%{table}
<a href='%{base_url}/admin/plugins/explorer?id=%{query_id}'>View query in Data Explorer</a>
<a href='%{base_url}/admin/plugins/explorer/queries/%{query_id}'>View query in Data Explorer</a>
Report created at %{created_at} (%{timezone})
upload_appendix: "Appendix: [%{filename}|attachment](%{short_url})"

View File

@ -3,12 +3,12 @@
DiscourseDataExplorer::Engine.routes.draw do
root to: "query#index"
get "queries" => "query#index"
get "queries/:id" => "query#show"
scope "/", defaults: { format: :json } do
get "schema" => "query#schema"
get "groups" => "query#groups"
post "queries" => "query#create"
get "queries/:id" => "query#show"
put "queries/:id" => "query#update"
delete "queries/:id" => "query#destroy"
post "queries/:id/run" => "query#run", :constraints => { format: /(json|csv)/ }

View File

@ -46,7 +46,7 @@ module ::DiscourseDataExplorer
sql = <<-SQL
/*
* DiscourseDataExplorer Query
* Query: /admin/plugins/explorer?id=#{query.id}
* Query: /admin/plugins/explorer/queries/#{query.id}
* Started by: #{opts[:current_user]}
*/
WITH query AS (

View File

@ -29,6 +29,32 @@ RSpec.describe "Explorer", type: :system, js: true do
end
end
context "with the old url format" do
fab!(:query_1) do
Fabricate(
:query,
name: "My query",
description: "Test query",
sql: "SELECT * FROM users",
user: admin,
)
end
it "redirects to the new url format" do
visit("/admin/plugins/explorer/?id=#{query_1.id}")
expect(page).to have_current_path("/admin/plugins/explorer/queries/#{query_1.id}")
end
it "redirects to the new url format with params" do
visit("/admin/plugins/explorer/?id=#{query_1.id}&params=%7B%22limit%22%3A%2210%22%7D")
expect(page).to have_current_path(
"/admin/plugins/explorer/queries/#{query_1.id}?params=%7B%22limit%22%3A%2210%22%7D",
)
end
end
context "with a group_list param" do
fab!(:q2) do
Fabricate(
@ -43,7 +69,7 @@ RSpec.describe "Explorer", type: :system, js: true do
it "supports setting a group_list param" do
visit(
"/admin/plugins/explorer?id=#{q2.id}&params=%7B\"groups\"%3A\"admins%2Ctrust_level_1\"%7D",
"/admin/plugins/explorer/queries/#{q2.id}?params=%7B\"groups\"%3A\"admins%2Ctrust_level_1\"%7D",
)
find(".query-run .btn-primary").click

View File

@ -62,7 +62,7 @@ RSpec.describe "Param input", type: :system, js: true do
end
it "correctly displays parameter input boxes" do
visit("/admin/plugins/explorer?id=#{all_params_query.id}")
visit("/admin/plugins/explorer/queries/#{all_params_query.id}")
::DiscourseDataExplorer::Parameter
.create_from_sql(ALL_PARAMS_SQL)

View File

@ -88,44 +88,22 @@ acceptance("Data Explorer Plugin | List Queries", function (needs) {
queries: [
{
id: -5,
sql: "-- [params]\n-- int :months_ago = 1\n\nWITH query_period AS\n(SELECT date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' AS period_start,\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' + INTERVAL '1 month' - INTERVAL '1 second' AS period_end)\nSELECT t.id AS topic_id,\n t.category_id,\n COUNT(p.id) AS reply_count\nFROM topics t\nJOIN posts p ON t.id = p.topic_id\nJOIN query_period qp ON p.created_at >= qp.period_start\nAND p.created_at <= qp.period_end\nWHERE t.archetype = 'regular'\nAND t.user_id > 0\nGROUP BY t.id\nORDER BY COUNT(p.id) DESC, t.score DESC\nLIMIT 100\n",
name: "Top 100 Active Topics",
description:
"based on the number of replies, it accepts a months_ago parameter, defaults to 1 to give results for the last calendar month.",
param_info: [
{
identifier: "months_ago",
type: "int",
default: "1",
nullable: false,
},
],
created_at: "2021-02-05T16:42:45.572Z",
username: "system",
group_ids: [],
last_run_at: "2021-02-08T15:37:49.188Z",
hidden: false,
user_id: -1,
},
{
id: -6,
sql: "-- [params]\n-- int :months_ago = 1\n\nWITH query_period AS (\n SELECT\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' as period_start,\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' + INTERVAL '1 month' - INTERVAL '1 second' as period_end\n )\n\n SELECT\n ua.user_id,\n count(1) AS like_count\n FROM user_actions ua\n INNER JOIN query_period qp\n ON ua.created_at >= qp.period_start\n AND ua.created_at <= qp.period_end\n WHERE ua.action_type = 1\n GROUP BY ua.user_id\n ORDER BY like_count DESC\n LIMIT 100\n",
name: "Top 100 Likers",
description:
"returns the top 100 likers for a given monthly period ordered by like_count. It accepts a months_ago parameter, defaults to 1 to give results for the last calendar month.",
param_info: [
{
identifier: "months_ago",
type: "int",
default: "1",
nullable: false,
},
],
created_at: "2021-02-02T12:21:11.449Z",
username: "system",
group_ids: [],
last_run_at: "2021-02-11T08:29:59.337Z",
hidden: false,
user_id: -1,
},
],

View File

@ -46,6 +46,32 @@ acceptance("Data Explorer Plugin | New Query", function (needs) {
},
});
});
server.get("/admin/plugins/explorer/queries/-15", () => {
return helper.response({
query: {
id: -15,
sql: "-- [params]\n-- int :months_ago = 1\n\nWITH query_period AS\n(SELECT date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' AS period_start,\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' + INTERVAL '1 month' - INTERVAL '1 second' AS period_end)\nSELECT t.id AS topic_id,\n t.category_id,\n COUNT(p.id) AS reply_count\nFROM topics t\nJOIN posts p ON t.id = p.topic_id\nJOIN query_period qp ON p.created_at >= qp.period_start\nAND p.created_at <= qp.period_end\nWHERE t.archetype = 'regular'\nAND t.user_id > 0\nGROUP BY t.id\nORDER BY COUNT(p.id) DESC, t.score DESC\nLIMIT 100\n",
name: "foo",
description:
"based on the number of replies, it accepts a months_ago parameter, defaults to 1 to give results for the last calendar month.",
param_info: [
{
identifier: "months_ago",
type: "int",
default: "1",
nullable: false,
},
],
created_at: "2021-02-05T16:42:45.572Z",
username: "system",
group_ids: [],
last_run_at: "2021-02-08T15:37:49.188Z",
hidden: false,
user_id: -1,
},
});
});
});
test("creates a new query", async function (assert) {
@ -57,6 +83,6 @@ acceptance("Data Explorer Plugin | New Query", function (needs) {
// select create new query button
await click(".query-create button");
assert.strictEqual(currentURL(), "/admin/plugins/explorer?id=-15");
assert.strictEqual(currentURL(), "/admin/plugins/explorer/queries/-15");
});
});

View File

@ -91,62 +91,62 @@ acceptance("Data Explorer Plugin | Param Input", function (needs) {
queries: [
{
id: -6,
sql: "-- [params]\n-- int :months_ago = 1\n\nWITH query_period AS (\n SELECT\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' as period_start,\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' + INTERVAL '1 month' - INTERVAL '1 second' as period_end\n )\n\n SELECT\n ua.user_id,\n count(1) AS like_count\n FROM user_actions ua\n INNER JOIN query_period qp\n ON ua.created_at >= qp.period_start\n AND ua.created_at <= qp.period_end\n WHERE ua.action_type = 1\n GROUP BY ua.user_id\n ORDER BY like_count DESC\n LIMIT 100\n",
name: "Top 100 Likers",
description:
"returns the top 100 likers for a given monthly period ordered by like_count. It accepts a months_ago parameter, defaults to 1 to give results for the last calendar month.",
param_info: [
{
identifier: "months_ago",
type: "int",
default: "1",
nullable: false,
},
],
created_at: "2021-02-02T12:21:11.449Z",
username: "system",
group_ids: [],
last_run_at: "2021-02-11T08:29:59.337Z",
hidden: false,
user_id: -1,
},
{
id: -7,
sql: "-- [params]\n-- user_id :user\n\nSELECT :user_id\n\n",
name: "Invalid Query",
description: "",
param_info: [
{
identifier: "user",
type: "user_id",
default: null,
nullable: false,
},
],
created_at: "2022-01-14T16:40:05.458Z",
username: "bianca",
group_ids: [],
last_run_at: "2022-01-14T16:47:34.244Z",
hidden: false,
user_id: 1,
},
{
id: 3,
sql: "SELECT 1",
name: "Params test",
description: "test for params.",
param_info: [],
created_at: "2021-02-02T12:21:11.449Z",
username: "system",
group_ids: [41],
last_run_at: "2021-02-11T08:29:59.337Z",
hidden: false,
user_id: -1,
},
],
});
});
server.get("/admin/plugins/explorer/queries/-6", () => {
return helper.response({
query: {
id: -6,
sql: "-- [params]\n-- int :months_ago = 1\n\nWITH query_period AS (\n SELECT\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' as period_start,\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' + INTERVAL '1 month' - INTERVAL '1 second' as period_end\n )\n\n SELECT\n ua.user_id,\n count(1) AS like_count\n FROM user_actions ua\n INNER JOIN query_period qp\n ON ua.created_at >= qp.period_start\n AND ua.created_at <= qp.period_end\n WHERE ua.action_type = 1\n GROUP BY ua.user_id\n ORDER BY like_count DESC\n LIMIT 100\n",
name: "Top 100 Likers",
description:
"returns the top 100 likers for a given monthly period ordered by like_count. It accepts a months_ago parameter, defaults to 1 to give results for the last calendar month.",
param_info: [
{
identifier: "months_ago",
type: "int",
default: "1",
nullable: false,
},
],
created_at: "2021-02-02T12:21:11.449Z",
username: "system",
group_ids: [],
last_run_at: "2021-02-11T08:29:59.337Z",
hidden: false,
user_id: -1,
},
});
});
server.put("/admin/plugins/explorer/queries/-6", () => {
return helper.response({
success: true,
@ -244,6 +244,31 @@ acceptance("Data Explorer Plugin | Param Input", function (needs) {
});
});
server.get("/admin/plugins/explorer/queries/-7", () => {
return helper.response({
query: {
id: -7,
sql: "-- [params]\n-- user_id :user\n\nSELECT :user_id\n\n",
name: "Invalid Query",
description: "",
param_info: [
{
identifier: "user",
type: "user_id",
default: null,
nullable: false,
},
],
created_at: "2022-01-14T16:40:05.458Z",
username: "bianca",
group_ids: [],
last_run_at: "2022-01-14T16:47:34.244Z",
hidden: false,
user_id: 1,
},
});
});
server.post("/admin/plugins/explorer/queries/-7/run", () => {
return helper.response({
success: true,
@ -351,7 +376,7 @@ acceptance("Data Explorer Plugin | Param Input", function (needs) {
});
test("puts params for the query into the url", async function (assert) {
await visit("/admin/plugins/explorer?id=-6");
await visit("/admin/plugins/explorer/queries/-6");
const monthsAgoValue = "2";
await fillIn(".query-params input", monthsAgoValue);
await click("form.query-run button");
@ -373,7 +398,7 @@ acceptance("Data Explorer Plugin | Param Input", function (needs) {
});
test("loads the page if one of the parameter is null", async function (assert) {
await visit('/admin/plugins/explorer?id=-7&params={"user":null}');
await visit('/admin/plugins/explorer/queries/-7?params={"user":null}');
assert.dom(".query-params .user-chooser").exists();
assert.dom(".query-run .btn.btn-primary").exists();
});
@ -393,7 +418,7 @@ acceptance("Data Explorer Plugin | Param Input", function (needs) {
});
test("creates input boxes if has parameters when save", async function (assert) {
await visit("/admin/plugins/explorer?id=3");
await visit("/admin/plugins/explorer/queries/3");
assert.dom(".query-params input").doesNotExist();
await click(".query-edit .btn-edit-query");
await click(".query-editor .ace_text-input");

View File

@ -88,42 +88,71 @@ acceptance("Data Explorer Plugin | Run Query", function (needs) {
queries: [
{
id: -6,
sql: "-- [params]\n-- int :months_ago = 1\n\nWITH query_period AS (\n SELECT\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' as period_start,\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' + INTERVAL '1 month' - INTERVAL '1 second' as period_end\n )\n\n SELECT\n ua.user_id,\n count(1) AS like_count\n FROM user_actions ua\n INNER JOIN query_period qp\n ON ua.created_at >= qp.period_start\n AND ua.created_at <= qp.period_end\n WHERE ua.action_type = 1\n GROUP BY ua.user_id\n ORDER BY like_count DESC\n LIMIT 100\n",
name: "Top 100 Likers",
description:
"returns the top 100 likers for a given monthly period ordered by like_count. It accepts a months_ago parameter, defaults to 1 to give results for the last calendar month.",
param_info: [
{
identifier: "months_ago",
type: "int",
default: "1",
nullable: false,
},
],
created_at: "2021-02-02T12:21:11.449Z",
username: "system",
group_ids: [],
last_run_at: "2021-02-11T08:29:59.337Z",
hidden: false,
user_id: -1,
},
{
id: 2,
sql: 'SELECT 0 zero, null "null", false "false"',
name: "What about 0?",
description: "",
param_info: [],
created_at: "2023-05-04T22:16:06.007Z",
username: "system",
group_ids: [],
last_run_at: "2023-05-04T22:16:23.858Z",
hidden: false,
user_id: 1,
},
],
});
});
server.get("/admin/plugins/explorer/queries/-6", () => {
return helper.response({
query: {
id: -6,
sql: "-- [params]\n-- int :months_ago = 1\n\nWITH query_period AS (\n SELECT\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' as period_start,\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' + INTERVAL '1 month' - INTERVAL '1 second' as period_end\n )\n\n SELECT\n ua.user_id,\n count(1) AS like_count\n FROM user_actions ua\n INNER JOIN query_period qp\n ON ua.created_at >= qp.period_start\n AND ua.created_at <= qp.period_end\n WHERE ua.action_type = 1\n GROUP BY ua.user_id\n ORDER BY like_count DESC\n LIMIT 100\n",
name: "Top 100 Likers",
description:
"returns the top 100 likers for a given monthly period ordered by like_count. It accepts a months_ago parameter, defaults to 1 to give results for the last calendar month.",
param_info: [
{
identifier: "months_ago",
type: "int",
default: "1",
nullable: false,
},
],
created_at: "2021-02-02T12:21:11.449Z",
username: "system",
group_ids: [],
last_run_at: "2021-02-11T08:29:59.337Z",
hidden: false,
user_id: -1,
},
});
});
server.get("/admin/plugins/explorer/queries/2", () => {
return helper.response({
query: {
id: 2,
sql: 'SELECT 0 zero, null "null", false "false"',
name: "What about 0?",
description: "",
param_info: [],
created_at: "2023-05-04T22:16:06.007Z",
username: "system",
group_ids: [],
last_run_at: "2023-05-04T22:16:23.858Z",
hidden: false,
user_id: 1,
},
});
});
server.post("/admin/plugins/explorer/queries/-6/run", () => {
return helper.response({
success: true,
@ -177,7 +206,7 @@ acceptance("Data Explorer Plugin | Run Query", function (needs) {
});
test("runs query and renders data and a chart", async function (assert) {
await visit("/admin/plugins/explorer?id=-6");
await visit("/admin/plugins/explorer/queries/-6");
assert
.dom("div.name h1")
@ -205,7 +234,7 @@ acceptance("Data Explorer Plugin | Run Query", function (needs) {
});
test("runs query and renders 0, false, and NULL values correctly", async function (assert) {
await visit("/admin/plugins/explorer?id=2");
await visit("/admin/plugins/explorer/queries/2");
assert
.dom("div.name h1")