minimal sql console API and UI
This commit is contained in:
@@ -197,7 +197,7 @@ export function pageCollections(route) {
|
||||
t.div(
|
||||
{ className: "page-content full-height" },
|
||||
t.header(
|
||||
{ className: "page-header compact flex-nowrap" },
|
||||
{ className: "page-header flex-nowrap" },
|
||||
t.nav(
|
||||
{ className: "breadcrumbs" },
|
||||
t.div(null, "Collections"),
|
||||
|
||||
@@ -35,3 +35,4 @@
|
||||
@import "./collectionsOverview.css";
|
||||
@import "./erd.css";
|
||||
@import "./hideControls.css";
|
||||
@import "./sqlConsole.css";
|
||||
|
||||
@@ -919,6 +919,7 @@ hr {
|
||||
.alert {
|
||||
display: block;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
align-content: center;
|
||||
min-height: var(--lgBtnHeight);
|
||||
padding: max(5px, calc(var(--inputPadding) - 5px)) max(10px, var(--inputPadding));
|
||||
|
||||
@@ -1131,6 +1131,7 @@ button {
|
||||
&:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--surfaceTxtDisabledColor);
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-10
@@ -174,16 +174,14 @@
|
||||
column-gap: var(--smSpacing);
|
||||
row-gap: calc(var(--smSpacing) - 5px);
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
margin: -5px 0 calc(var(--spacing) - 5px);
|
||||
min-height: var(--btnHeight);
|
||||
margin: -10px 0 calc(var(--spacing) - 10px);
|
||||
.searchbar {
|
||||
width: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
&.compact {
|
||||
margin-top: -10px;
|
||||
margin-bottom: calc(var(--spacing) - 10px);
|
||||
}
|
||||
.page-header-secondary-btns,
|
||||
.page-header-primary-btns {
|
||||
display: inline-flex;
|
||||
@@ -206,10 +204,6 @@
|
||||
}
|
||||
@media (max-width: 950px) {
|
||||
gap: 5px;
|
||||
flex-grow: 1;
|
||||
.flex-nowrap & {
|
||||
flex-grow: 0;
|
||||
}
|
||||
.btn {
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
@@ -219,7 +213,6 @@
|
||||
}
|
||||
@media (max-width: 550px) {
|
||||
gap: 5px;
|
||||
flex-grow: 0;
|
||||
.api-preview-btn {
|
||||
display: none;
|
||||
}
|
||||
@@ -228,6 +221,7 @@
|
||||
width: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 50%;
|
||||
min-width: 0;
|
||||
i + .txt {
|
||||
display: none;
|
||||
}
|
||||
@@ -241,6 +235,7 @@
|
||||
align-items: center;
|
||||
width: auto;
|
||||
z-index: 10;
|
||||
gap: var(--smSpacing);
|
||||
color: var(--surfaceTxtHintColor);
|
||||
font-size: var(--smFontSize);
|
||||
line-height: var(--smLineHeight);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.field.sql-console-field {
|
||||
.input {
|
||||
resize: vertical;
|
||||
height: 250px;
|
||||
min-height: 150px;
|
||||
max-height: 400px;
|
||||
|
||||
@media (max-height: 650px) {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sql-console-history-dropdown {
|
||||
width: 450px;
|
||||
&.no-items {
|
||||
width: 300px;
|
||||
}
|
||||
.dropdown-item {
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.2;
|
||||
font-family: var(--monospaceFontFamily);
|
||||
.query {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,22 @@ table {
|
||||
min-width: 900px;
|
||||
border-radius: 0;
|
||||
}
|
||||
table > :first-child > tr:first-child {
|
||||
> :first-child {
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
> :last-child {
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
table > :last-child > tr:last-child {
|
||||
> :first-child {
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
> :last-child {
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
tr {
|
||||
animation: fadeIn var(--animationSpeed);
|
||||
}
|
||||
@@ -342,6 +358,7 @@ table {
|
||||
td,
|
||||
th {
|
||||
height: 59px;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
line-height: var(--smLineHeight);
|
||||
font-family: var(--baseFontFamily);
|
||||
scrollbar-width: thin;
|
||||
margin: 3px;
|
||||
padding: 3px 5px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
|
||||
@@ -165,6 +165,8 @@ export function logsList(logsSettings) {
|
||||
return; // nothing to download
|
||||
}
|
||||
|
||||
console.log(data.bulkSelected);
|
||||
|
||||
if (selected.length == 1) {
|
||||
return app.utils.downloadJSON(
|
||||
selected[0],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { pageApplicationSettings } from "@/settings/application/pageApplicationS
|
||||
import { pageBackupsSettings } from "@/settings/backups/pageBackupsSettings";
|
||||
import { pageCronsSettings } from "@/settings/crons/pageCronsSettings";
|
||||
import { pageMailSettings } from "@/settings/mail/pageMailSettings";
|
||||
import { pageSQLConsole } from "@/settings/sql/pageSQLConsole";
|
||||
import { pageStorageSettings } from "@/settings/storage/pageStorageSettings";
|
||||
import { pageExportCollections } from "@/settings/sync/pageExportCollections";
|
||||
import { pageImportCollections } from "@/settings/sync/pageImportCollections";
|
||||
@@ -170,3 +171,4 @@ app.routes.superuserOnly("#/settings/backups", pageBackupsSettings);
|
||||
app.routes.superuserOnly("#/settings/crons", pageCronsSettings);
|
||||
app.routes.superuserOnly("#/settings/export-collections", pageExportCollections);
|
||||
app.routes.superuserOnly("#/settings/import-collections", pageImportCollections);
|
||||
app.routes.superuserOnly("#/settings/sql", pageSQLConsole);
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
import { settingsSidebar } from "../settingsSidebar";
|
||||
|
||||
const SQL_HISTORY_STORAGE_KEY = "pbSQLConsoleHistory";
|
||||
|
||||
export function pageSQLConsole(route) {
|
||||
app.store.title = "SQL console";
|
||||
|
||||
const uniqueId = "sql_console_" + app.utils.randomString();
|
||||
const editorId = uniqueId + "editor";
|
||||
const requestKey = uniqueId + "executeSQL";
|
||||
const defaultMaxRows = 250;
|
||||
|
||||
const pageData = store({
|
||||
askedForConfirmationAtLeastOnce: false,
|
||||
isExecuting: false,
|
||||
maxRows: defaultMaxRows,
|
||||
query: "",
|
||||
result: {},
|
||||
sort: {},
|
||||
errorMsg: "",
|
||||
executedHistory: app.utils.getLocalHistory(SQL_HISTORY_STORAGE_KEY, []),
|
||||
|
||||
get sortedResultRows() {
|
||||
// no client-side sort
|
||||
if (typeof pageData.sort?.index == "undefined") {
|
||||
const rows = pageData.result?.rows || [];
|
||||
return pageData.maxRows >= rows.length ? rows : rows.slice(0, pageData.maxRows);
|
||||
}
|
||||
|
||||
const isAsc = !!pageData.sort?.asc;
|
||||
|
||||
const sorted = pageData.result?.rows?.toSorted((rowA, rowB) => {
|
||||
const valA = rowA[pageData.sort.index];
|
||||
const valB = rowB[pageData.sort.index];
|
||||
|
||||
if (isAsc) {
|
||||
if (valA == valB) {
|
||||
return 0;
|
||||
}
|
||||
if (valA == null) {
|
||||
return -1;
|
||||
}
|
||||
if (valB == null) {
|
||||
return 1;
|
||||
}
|
||||
return valA.localeCompare(valB);
|
||||
}
|
||||
|
||||
if (valA == valB) {
|
||||
return 0;
|
||||
}
|
||||
if (valA == null) {
|
||||
return 1;
|
||||
}
|
||||
if (valB == null) {
|
||||
return -1;
|
||||
}
|
||||
return valB.localeCompare(valA);
|
||||
}) || [];
|
||||
|
||||
return pageData.maxRows >= sorted.length ? sorted : sorted.slice(0, pageData.maxRows);
|
||||
},
|
||||
get totalRemainingRows() {
|
||||
return (pageData.result?.rows?.length << 0) - pageData.sortedResultRows.length;
|
||||
},
|
||||
});
|
||||
|
||||
function needConfirmation(query) {
|
||||
if (!query?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the hideControls option is enabled ask for confirmation
|
||||
// at least once no matter of the query
|
||||
if (
|
||||
!pageData.askedForConfirmationAtLeastOnce
|
||||
&& !!app.store.settings?.meta?.hideControls
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
query = query?.replace(/[\s\;]/gm, " ").toUpperCase() + " ";
|
||||
|
||||
return !![
|
||||
"INSERT ",
|
||||
"CREATE ",
|
||||
"UPDATE ",
|
||||
"DELETE ",
|
||||
"DROP ",
|
||||
"DETACH ",
|
||||
"PRAGMA ",
|
||||
].find((p) => query.includes(p));
|
||||
}
|
||||
|
||||
async function executeSQLWithConfirm() {
|
||||
if (!needConfirmation(pageData.query)) {
|
||||
return executeSQL();
|
||||
}
|
||||
|
||||
pageData.askedForConfirmationAtLeastOnce = true;
|
||||
|
||||
return app.modals.confirm(
|
||||
t.div(
|
||||
{ className: "txt-center" },
|
||||
t.h6(
|
||||
null,
|
||||
"Be careful and continue only if you really know what you are doing because, depending on the query, the operation could break your application and may not be reversible.",
|
||||
),
|
||||
),
|
||||
() => executeSQL(),
|
||||
null,
|
||||
{ yesButton: "Execute", noButton: "Cancel" },
|
||||
);
|
||||
}
|
||||
|
||||
async function executeSQL() {
|
||||
pageData.isExecuting = true;
|
||||
pageData.maxRows = defaultMaxRows;
|
||||
pageData.result = {};
|
||||
pageData.sort = {};
|
||||
pageData.errorMsg = "";
|
||||
|
||||
const query = pageData.query.trim();
|
||||
if (!query) {
|
||||
app.pb.cancelRequest(requestKey);
|
||||
pageData.isExecuting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// @todo add method to JS SDK
|
||||
pageData.result = await app.pb.send("/api/sql", {
|
||||
method: "POST",
|
||||
body: { query },
|
||||
requestKey: requestKey,
|
||||
});
|
||||
|
||||
addToHistory(query);
|
||||
|
||||
pageData.isExecuting = false;
|
||||
} catch (err) {
|
||||
if (!err?.isAbort) {
|
||||
pageData.isExecuting = false;
|
||||
pageData.errorMsg = err?.response?.message || err?.message || "Failed to execute query.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromHistory(query) {
|
||||
function looseNormalize(str) {
|
||||
return str.replace(/[\s\;]/gm, "").toUpperCase();
|
||||
}
|
||||
|
||||
const normalized = looseNormalize(query);
|
||||
|
||||
for (let i = pageData.executedHistory.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
pageData.executedHistory[i] == query
|
||||
|| looseNormalize(pageData.executedHistory[i]) == normalized
|
||||
) {
|
||||
pageData.executedHistory.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addToHistory(query) {
|
||||
removeFromHistory(query);
|
||||
|
||||
pageData.executedHistory.unshift(pageData.query);
|
||||
if (pageData.executedHistory.length > 10) {
|
||||
pageData.executedHistory.splice(10);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCSV() {
|
||||
if (!pageData.sortedResultRows.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = [pageData.result.columns.map((c) => c.name)].concat(pageData.sortedResultRows);
|
||||
|
||||
const name = "export_" + app.utils.toLocalDatetime(new Date()).replace(/[\-\:\. ]/g, "_") + ".csv";
|
||||
|
||||
app.utils.downloadCSV(data, name);
|
||||
}
|
||||
|
||||
function toggleSort(index) {
|
||||
if (pageData.sort?.index == index) {
|
||||
pageData.sort = {
|
||||
index: index,
|
||||
asc: !pageData.sort.asc,
|
||||
};
|
||||
} else {
|
||||
pageData.sort = {
|
||||
index: index,
|
||||
asc: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const watchers = [
|
||||
watch(() => JSON.stringify(pageData.executedHistory), (newVal, oldVal) => {
|
||||
if (typeof oldVal == "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(SQL_HISTORY_STORAGE_KEY, newVal);
|
||||
}),
|
||||
];
|
||||
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "pageSQLConsole",
|
||||
className: "page",
|
||||
onunmount: () => {
|
||||
app.pb.cancelRequest(requestKey);
|
||||
watchers.forEach((w) => w?.unwatch());
|
||||
},
|
||||
},
|
||||
settingsSidebar(),
|
||||
t.div(
|
||||
{ className: "page-content full-height" },
|
||||
t.header(
|
||||
{ className: "page-header" },
|
||||
t.nav(
|
||||
{ className: "breadcrumbs" },
|
||||
t.div({ className: "breadcrumb-item" }, "Settings"),
|
||||
t.div({ className: "breadcrumb-item" }, () => app.store.title),
|
||||
),
|
||||
t.div(
|
||||
{ className: "page-header-secondary-btns" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn circle transparent secondary",
|
||||
ariaDescription: app.attrs.tooltip("Recently executed queries", "right"),
|
||||
"html-popovertarget": "sql-console-history-dropdown",
|
||||
},
|
||||
t.i({ className: "ri-history-line", ariaHidden: true }),
|
||||
),
|
||||
t.div(
|
||||
{
|
||||
id: "sql-console-history-dropdown",
|
||||
className: () =>
|
||||
`dropdown left sql-console-history-dropdown ${
|
||||
!pageData.executedHistory.length ? "no-items" : ""
|
||||
}`,
|
||||
popover: "auto",
|
||||
},
|
||||
() => {
|
||||
if (!pageData.executedHistory.length) {
|
||||
return t.span({ className: "txt txt-hint p-5" }, "No recently executed queries.");
|
||||
}
|
||||
|
||||
return pageData.executedHistory.map((item) => {
|
||||
return t.button(
|
||||
{
|
||||
role: "button",
|
||||
className: "dropdown-item",
|
||||
onclick: (e) => {
|
||||
e.target.closest(".dropdown").hidePopover();
|
||||
pageData.query = item;
|
||||
|
||||
document.getElementById(editorId)?.click();
|
||||
},
|
||||
},
|
||||
t.span(
|
||||
{ className: "query" },
|
||||
() => app.utils.truncate(item, 500),
|
||||
),
|
||||
t.small(
|
||||
{
|
||||
role: "button",
|
||||
className: "remove-btn link-hint m-l-auto p-l-5 p-r-5",
|
||||
title: "Clear",
|
||||
onauxclick: (e) => {
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
},
|
||||
onclick: (e) => {
|
||||
e.stopPropagation();
|
||||
removeFromHistory(item);
|
||||
return false;
|
||||
},
|
||||
},
|
||||
t.i({ className: "ri-close-line", ariaHidden: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "page-header-primary-btns" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: () => `btn expanded-lg ${pageData.isExecuting ? "loading" : ""}`,
|
||||
disabled: () => pageData.isExecuting,
|
||||
onclick: () => executeSQLWithConfirm(),
|
||||
},
|
||||
t.i({ className: "ri-play-large-line", ariaHidden: true }),
|
||||
t.span({ className: "txt" }, "Execute"),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field sql-console-field" },
|
||||
app.components.codeEditor({
|
||||
id: editorId,
|
||||
language: "sql",
|
||||
required: true,
|
||||
name: "query",
|
||||
placeholder: "e.g. EXPLAIN QUERY PLAN SELECT * from users WHERE verified=true",
|
||||
value: () => pageData.query,
|
||||
oninput: (val) => pageData.query = val,
|
||||
onblur: (val) => pageData.query = val.trim(),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "flex field-help m-b-sm" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "link-hint m-l-auto",
|
||||
"html-popovertarget": uniqueId + "caveats_dropdown",
|
||||
},
|
||||
() => "SQL console caveats",
|
||||
),
|
||||
t.div(
|
||||
{
|
||||
id: uniqueId + "caveats_dropdown",
|
||||
className: "dropdown sm query-caveats-dropdown",
|
||||
popover: "auto",
|
||||
},
|
||||
t.ul(
|
||||
null,
|
||||
t.li(null, "The returned rows are limited up to 1000."),
|
||||
t.li(null, "The executed queries have a max timeout of 3 minutes."),
|
||||
t.li(null, "The data is returned as byte strings without any additional formatting."),
|
||||
t.li(null, "Multiple queries are supported but only the result of the last one is returned."),
|
||||
),
|
||||
),
|
||||
),
|
||||
// error alert
|
||||
t.div(
|
||||
{
|
||||
hidden: () => pageData.isExecuting || !pageData.errorMsg,
|
||||
className: "alert danger m-b-sm",
|
||||
},
|
||||
t.pre(null, () => pageData.errorMsg),
|
||||
),
|
||||
// success alert
|
||||
t.div(
|
||||
{
|
||||
hidden: () =>
|
||||
pageData.isExecuting || pageData.errorMsg || pageData.result?.columns?.length
|
||||
|| app.utils.isEmpty(pageData.result),
|
||||
className: "alert success m-b-sm",
|
||||
},
|
||||
t.p({ className: "txt-bold" }, "Query executed successfully!"),
|
||||
t.p(null, "Affected rows: ", () => pageData.result?.affectedRows || 0),
|
||||
),
|
||||
// rows
|
||||
t.div(
|
||||
{
|
||||
hidden: () => pageData.isExecuting || !pageData.result?.columns?.length,
|
||||
className: "page-table-wrapper",
|
||||
},
|
||||
t.table(
|
||||
{ className: "sql-console-table responsive-table optimize" },
|
||||
t.thead(
|
||||
{ className: "sticky" },
|
||||
t.tr(null, () => {
|
||||
return pageData.result?.columns?.map((col, i) => {
|
||||
return t.th({
|
||||
textContent: col.name,
|
||||
className: () => {
|
||||
let classes = "sort-handle";
|
||||
|
||||
if (pageData.sort?.index == i) {
|
||||
classes += pageData.sort.asc ? " asc" : " desc";
|
||||
}
|
||||
|
||||
return classes;
|
||||
},
|
||||
onclick: () => toggleSort(i),
|
||||
});
|
||||
});
|
||||
}),
|
||||
),
|
||||
t.tbody(
|
||||
null,
|
||||
() => {
|
||||
if (!pageData.sortedResultRows.length) {
|
||||
return t.tr(
|
||||
null,
|
||||
t.td(
|
||||
{ colSpan: pageData.result?.columns?.length || 1, className: "txt-center" },
|
||||
t.span({ className: "txt-hint" }, "No rows found."),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return pageData.sortedResultRows.map((rowData) => {
|
||||
return t.tr(
|
||||
null,
|
||||
() => {
|
||||
return pageData.result?.columns?.map((col, j) => {
|
||||
const val = rowData[j];
|
||||
return t.td(
|
||||
{ "html-data-name": col.name },
|
||||
val == null ? "NULL" : app.utils.truncate(val, 2000),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
// load more btn
|
||||
t.tr(
|
||||
{
|
||||
hidden: () => (
|
||||
pageData.isExecuting
|
||||
|| !pageData.result?.rows?.length
|
||||
|| pageData.result.rows.length <= pageData.sortedResultRows.length
|
||||
),
|
||||
},
|
||||
t.td(
|
||||
{ colSpan: 99 },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn lg secondary load-more-btn",
|
||||
onclick: () => {
|
||||
pageData.maxRows = pageData.result?.rows?.length || defaultMaxRows;
|
||||
},
|
||||
},
|
||||
t.span({
|
||||
className: "txt",
|
||||
textContent: () => `Load remaining (${pageData.totalRemainingRows})`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.footer(
|
||||
{ className: "page-footer" },
|
||||
t.span(
|
||||
{
|
||||
className: () => `exec-time ${pageData.isExecuting ? "faded" : ""}`,
|
||||
},
|
||||
"Time: ",
|
||||
() => (pageData.result?.execTime || 0) + "ms",
|
||||
),
|
||||
t.span(
|
||||
{
|
||||
hidden: () => !pageData.result?.columns?.length,
|
||||
className: () => `total-count ${pageData.isExecuting ? "faded" : ""}`,
|
||||
},
|
||||
"Rows: ",
|
||||
() => pageData.result?.rows?.length || 0,
|
||||
() => {
|
||||
if (!pageData.result?.rows?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return [
|
||||
" (",
|
||||
t.span({
|
||||
role: "button",
|
||||
className: "link-hint",
|
||||
textContent: "Export as CSV",
|
||||
onclick: downloadCSV,
|
||||
}),
|
||||
")",
|
||||
];
|
||||
},
|
||||
),
|
||||
app.components.credits(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
+8
-1
@@ -115,6 +115,13 @@ window.app.store = store({
|
||||
label: "Import collections",
|
||||
},
|
||||
],
|
||||
Debug: [
|
||||
{
|
||||
href: "#/settings/sql",
|
||||
icon: "ri-terminal-box-line",
|
||||
label: "SQL console",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
predefinedAccentColors: [
|
||||
@@ -205,7 +212,7 @@ window.app.store = store({
|
||||
addOrUpdateCollection(collection) {
|
||||
const index = app.store.collections.findIndex((c) => c.id == collection.id);
|
||||
if (index >= 0) {
|
||||
if (app.store.activeCollection.id == collection.id) {
|
||||
if (app.store.activeCollection?.id == collection.id) {
|
||||
app.store._activeCollectionIdOrName = collection.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -844,6 +844,29 @@ const utils = {
|
||||
utils.download(url, name);
|
||||
},
|
||||
|
||||
/**
|
||||
* Downloads a CSV file created from the provide array data.
|
||||
*
|
||||
* @param {Array} arr The JS array to serialize as CSV
|
||||
* @param {string} name The result file name.
|
||||
*/
|
||||
downloadCSV(arr, name) {
|
||||
name = name.endsWith(".csv") ? name : name + ".csv";
|
||||
|
||||
// seriaze data
|
||||
let content = arr.map((row) => {
|
||||
return row.map((v) => "\"" + ("" + v).replaceAll("\"", "\"\"") + "\"").join(",");
|
||||
}).join("\n");
|
||||
|
||||
const blob = new Blob([content], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
utils.download(url, name);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns a normalized API URL address that is used in the API example docs.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user