mirror of
https://github.com/knadh/listmonk.git
synced 2025-10-08 06:18:27 +08:00
Extracting SQLCounter and SQLEditor components for enhanced SQL snippet functionality
This commit is contained in:
parent
2084fb1e94
commit
c9b9adeff5
5 changed files with 507 additions and 323 deletions
195
frontend/src/components/SQLCounter.vue
Normal file
195
frontend/src/components/SQLCounter.vue
Normal file
|
@ -0,0 +1,195 @@
|
|||
<template>
|
||||
<div v-if="query && query.trim() !== ''" class="sql-counter mb-3">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<div class="notification is-light p-3">
|
||||
<div class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<b-icon icon="account-search" size="is-small" class="has-text-info" />
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div v-if="subscriberCount.loading" class="is-flex is-align-items-center">
|
||||
<b-loading :is-full-page="false" v-model="subscriberCount.loading" :can-cancel="false" />
|
||||
<span class="is-size-6 has-text-grey ml-2">{{ $t('globals.messages.loading') }}...</span>
|
||||
</div>
|
||||
<div v-else-if="subscriberCount.error" class="has-text-danger is-size-6">
|
||||
<b-icon icon="alert-circle" size="is-small" class="mr-1" />
|
||||
{{ $t('sqlSnippets.invalidQuery') }}
|
||||
</div>
|
||||
<div v-else class="is-size-6">
|
||||
<span class="has-text-weight-semibold has-text-info">{{ subscriberCount.found.toLocaleString() }}</span>
|
||||
{{ $t('subscribers.matchingSubscribers') }}
|
||||
<span class="has-text-grey">
|
||||
({{ $t('subscribers.outOfTotal', { total: subscriberCount.total.toLocaleString() }) }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showLiveToggle" class="level-right">
|
||||
<div class="level-item">
|
||||
<b-field>
|
||||
<b-checkbox v-model="isLiveValidationEnabled" size="is-small">
|
||||
{{ $t('sqlSnippets.liveValidation') }}
|
||||
</b-checkbox>
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SQLCounter',
|
||||
props: {
|
||||
query: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showLiveToggle: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
liveValidationEnabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
subscriberCount: {
|
||||
loading: false,
|
||||
error: false,
|
||||
found: 0,
|
||||
total: 0,
|
||||
},
|
||||
countDebounceTimer: null,
|
||||
isLiveValidationEnabled: this.liveValidationEnabled,
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
query(newQuery) {
|
||||
if (this.isLiveValidationEnabled) {
|
||||
this.updateSubscriberCount(newQuery);
|
||||
} else {
|
||||
// Reset state when live validation is disabled
|
||||
this.subscriberCount.loading = false;
|
||||
this.subscriberCount.error = false;
|
||||
this.subscriberCount.found = 0;
|
||||
}
|
||||
},
|
||||
|
||||
isLiveValidationEnabled(enabled) {
|
||||
this.$emit('update:liveValidationEnabled', enabled);
|
||||
if (enabled && this.query) {
|
||||
this.updateSubscriberCount(this.query, true); // immediate when toggling on
|
||||
} else {
|
||||
// Reset state when disabled
|
||||
this.subscriberCount.loading = false;
|
||||
this.subscriberCount.error = false;
|
||||
this.subscriberCount.found = 0;
|
||||
}
|
||||
},
|
||||
|
||||
liveValidationEnabled(enabled) {
|
||||
this.isLiveValidationEnabled = enabled;
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadTotalSubscriberCount();
|
||||
// Always run initial count if there's a query, regardless of live validation setting
|
||||
if (this.query) {
|
||||
this.updateSubscriberCount(this.query, true); // immediate = true for initial load
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
if (this.countDebounceTimer) {
|
||||
clearTimeout(this.countDebounceTimer);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// Load total subscriber count
|
||||
loadTotalSubscriberCount() {
|
||||
this.$api.countSQLSnippet({ query_sql: '' }).then((data) => {
|
||||
this.subscriberCount.total = data.total || 0;
|
||||
}).catch(() => {
|
||||
// Silently fail for total count
|
||||
this.subscriberCount.total = 0;
|
||||
});
|
||||
},
|
||||
|
||||
// Update subscriber count with debouncing
|
||||
updateSubscriberCount(query, immediate = false) {
|
||||
if (this.countDebounceTimer) {
|
||||
clearTimeout(this.countDebounceTimer);
|
||||
}
|
||||
|
||||
// Don't count if query is empty
|
||||
if (!query || query.trim() === '') {
|
||||
this.subscriberCount.found = 0;
|
||||
this.subscriberCount.error = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const executeCount = () => {
|
||||
this.subscriberCount.loading = true;
|
||||
this.subscriberCount.error = false;
|
||||
|
||||
this.$api.countSQLSnippet({ query_sql: query }).then((data) => {
|
||||
this.subscriberCount.found = data.matched || 0;
|
||||
this.subscriberCount.total = data.total || this.subscriberCount.total;
|
||||
this.subscriberCount.loading = false;
|
||||
}).catch(() => {
|
||||
// Only show error state in UI, don't show toast for live validation
|
||||
this.subscriberCount.error = true;
|
||||
this.subscriberCount.loading = false;
|
||||
});
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
// Execute immediately for initial load
|
||||
executeCount();
|
||||
} else {
|
||||
// Use longer debounce time to reduce annoying error messages
|
||||
this.countDebounceTimer = setTimeout(executeCount, 1500);
|
||||
}
|
||||
},
|
||||
|
||||
// Manual validation (for when live validation is disabled)
|
||||
validateQuery() {
|
||||
if (!this.query || this.query.trim() === '') {
|
||||
this.$utils.toast(this.$t('sqlSnippets.emptyQuery'), 'is-warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateSubscriberCount(this.query);
|
||||
},
|
||||
|
||||
// Update count immediately (public method for programmatic calls)
|
||||
updateImmediately() {
|
||||
if (this.query) {
|
||||
this.updateSubscriberCount(this.query, true);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sql-counter {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
163
frontend/src/components/SQLEditor.vue
Normal file
163
frontend/src/components/SQLEditor.vue
Normal file
|
@ -0,0 +1,163 @@
|
|||
<template>
|
||||
<div class="sql-editor">
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<b-input
|
||||
:value="value"
|
||||
@input="$emit('input', $event)"
|
||||
@keydown.native.enter="$emit('enter')"
|
||||
type="textarea"
|
||||
:placeholder="placeholder"
|
||||
:rows="rows"
|
||||
ref="textarea"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showSnippetsButton" class="control" style="position: relative;">
|
||||
<b-button
|
||||
@click="toggleSnippetsDropdown($event)"
|
||||
type="is-light"
|
||||
icon-left="code"
|
||||
:class="{ 'is-info': showSnippetsDropdown }"
|
||||
:disabled="sqlSnippets.length === 0"
|
||||
:title="`Snippets count: ${sqlSnippets.length}`"
|
||||
>
|
||||
{{ $t('sqlSnippets.snippet') }} ({{ sqlSnippets.length }})
|
||||
</b-button>
|
||||
|
||||
<!-- SQL Snippets Dropdown -->
|
||||
<div v-if="showSnippetsDropdown" class="dropdown is-active" style="position: absolute; top: 100%; right: 0; width: 400px; z-index: 9999;">
|
||||
<div class="dropdown-menu" style="width: 100%;">
|
||||
<div class="dropdown-content">
|
||||
<div v-if="sqlSnippets.length === 0" class="dropdown-item has-text-grey">
|
||||
<em>{{ $t('globals.messages.emptyState') }}</em>
|
||||
</div>
|
||||
<a v-for="snippet in sqlSnippets" :key="snippet.id"
|
||||
@click="selectSQLSnippet(snippet)"
|
||||
@keydown.enter="selectSQLSnippet(snippet)"
|
||||
class="dropdown-item"
|
||||
tabindex="0"
|
||||
style="cursor: pointer;">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<b-icon icon="code" size="is-small" class="has-text-info" />
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div class="content">
|
||||
<p class="is-size-6 has-text-weight-semibold mb-1">{{ snippet.name }}</p>
|
||||
<p v-if="snippet.description" class="is-size-7 has-text-grey mb-2">{{ snippet.description }}</p>
|
||||
<p class="is-size-7 has-text-dark">
|
||||
<code class="has-background-light">{{ snippet.querySql || snippet.query_sql }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SQLEditor',
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Enter SQL query...',
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
},
|
||||
showSnippetsButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
sqlSnippets: [],
|
||||
showSnippetsDropdown: false,
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.showSnippetsButton) {
|
||||
this.loadSQLSnippets();
|
||||
// Add click outside listener for dropdown
|
||||
document.addEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
if (this.showSnippetsButton) {
|
||||
// Remove click outside listener
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// Load active SQL snippets for autocomplete
|
||||
loadSQLSnippets() {
|
||||
this.$api.getSQLSnippets({ is_active: true }).then((data) => {
|
||||
// API returns the array directly, not wrapped in results
|
||||
this.sqlSnippets = data || [];
|
||||
}).catch(() => {
|
||||
// Silently fail for SQL snippets loading - this is optional functionality
|
||||
this.sqlSnippets = [];
|
||||
});
|
||||
},
|
||||
|
||||
// Handle SQL snippet selection
|
||||
selectSQLSnippet(snippet) {
|
||||
if (snippet && (snippet.querySql || snippet.query_sql)) {
|
||||
const sqlQuery = snippet.querySql || snippet.query_sql;
|
||||
this.$emit('input', sqlQuery);
|
||||
this.$emit('snippet-selected', sqlQuery);
|
||||
this.showSnippetsDropdown = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle snippets dropdown
|
||||
toggleSnippetsDropdown(event) {
|
||||
event.stopPropagation();
|
||||
this.showSnippetsDropdown = !this.showSnippetsDropdown;
|
||||
},
|
||||
|
||||
// Handle clicks outside dropdown to close it
|
||||
handleClickOutside(event) {
|
||||
const dropdown = event.target.closest('.dropdown');
|
||||
const button = event.target.closest('.control');
|
||||
|
||||
if (!dropdown && !button) {
|
||||
this.showSnippetsDropdown = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Focus the textarea
|
||||
focus() {
|
||||
this.$refs.textarea.focus();
|
||||
},
|
||||
|
||||
// Refresh SQL snippets (public method)
|
||||
refreshSnippets() {
|
||||
this.loadSQLSnippets();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sql-editor {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
|
@ -132,81 +132,22 @@
|
|||
{{ $t('sqlSnippets.queryHelp') }}
|
||||
</p>
|
||||
|
||||
<!-- Live subscriber count -->
|
||||
<div class="mt-3">
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<div class="tags has-addons">
|
||||
<span class="tag is-light">
|
||||
<b-icon icon="account-group" size="is-small" class="mr-1" />
|
||||
Matches
|
||||
</span>
|
||||
<span v-if="!liveValidationEnabled" class="tag is-light">
|
||||
<span class="has-text-grey is-italic">Live validation disabled</span>
|
||||
</span>
|
||||
<span v-else-if="subscriberCount.loading" class="tag is-info">
|
||||
<b-icon icon="loading" class="is-rotating" size="is-small" />
|
||||
</span>
|
||||
<span v-else-if="subscriberCount.error" class="tag is-danger">
|
||||
Error
|
||||
</span>
|
||||
<span v-else-if="form.querySql.trim()" class="tag is-success">
|
||||
{{ subscriberCount.found.toLocaleString() }}
|
||||
</span>
|
||||
<span v-else class="tag is-light">
|
||||
-
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item ml-4">
|
||||
<div class="has-text-grey is-size-7">
|
||||
Total subscribers: {{ subscriberCount.total.toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SQL Counter -->
|
||||
<SQLCounter
|
||||
:query="form.querySql"
|
||||
:live-validation-enabled.sync="liveValidationEnabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<div class="column">
|
||||
<b-field>
|
||||
<b-checkbox v-model="form.is_active">
|
||||
{{ $t('globals.fields.status') }}
|
||||
</b-checkbox>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column is-6 has-text-right">
|
||||
<div class="mb-3">
|
||||
<label class="checkbox is-flex is-justify-content-flex-end is-align-items-center">
|
||||
<input type="checkbox" v-model="liveValidationEnabled" @change="onLiveValidationChange" class="mr-2">
|
||||
<b-icon icon="flash" size="is-small" class="mr-1" />
|
||||
<span class="is-size-7">Live SQL validation</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="is-flex is-justify-content-flex-end is-align-items-center">
|
||||
<div v-if="validationMessage" class="mr-3">
|
||||
<b-icon
|
||||
:icon="validationMessage.type === 'is-success' ? 'check-circle' : 'alert-circle'"
|
||||
:type="validationMessage.type === 'is-success' ? 'is-success' : 'is-danger'"
|
||||
size="is-small"
|
||||
:title="validationMessage.text"
|
||||
/>
|
||||
</div>
|
||||
<b-button
|
||||
v-if="!liveValidationEnabled"
|
||||
@click="validateQuery"
|
||||
type="is-info"
|
||||
icon-left="check"
|
||||
:loading="isValidating"
|
||||
size="is-small"
|
||||
>
|
||||
{{ $t('sqlSnippets.validate') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
|
@ -223,27 +164,20 @@
|
|||
|
||||
<script>
|
||||
import CodeEditor from '../components/CodeEditor.vue';
|
||||
import SQLCounter from '../components/SQLCounter.vue';
|
||||
|
||||
export default {
|
||||
name: 'SqlSnippets',
|
||||
|
||||
components: {
|
||||
CodeEditor,
|
||||
SQLCounter,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
sqlSnippets: [],
|
||||
form: this.initForm(),
|
||||
isValidating: false,
|
||||
validationMessage: null,
|
||||
subscriberCount: {
|
||||
loading: false,
|
||||
error: false,
|
||||
found: 0,
|
||||
total: 0,
|
||||
},
|
||||
countDebounceTimer: null,
|
||||
liveValidationEnabled: true,
|
||||
};
|
||||
},
|
||||
|
@ -268,7 +202,6 @@ export default {
|
|||
|
||||
showForm(snippet = null) {
|
||||
this.form = this.initForm();
|
||||
this.validationMessage = null;
|
||||
|
||||
if (snippet) {
|
||||
// If editing existing snippet, fetch full data including querySql
|
||||
|
@ -368,115 +301,15 @@ export default {
|
|||
});
|
||||
},
|
||||
|
||||
validateQuery() {
|
||||
if (!this.form.querySql.trim()) {
|
||||
this.validationMessage = {
|
||||
type: 'is-warning',
|
||||
text: this.$t('sqlSnippets.emptyQuery'),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
this.isValidating = true;
|
||||
this.validationMessage = null;
|
||||
|
||||
this.$api.validateSQLSnippet({ query_sql: this.form.querySql }).then(() => {
|
||||
this.validationMessage = {
|
||||
type: 'is-success',
|
||||
text: this.$t('sqlSnippets.validQuery'),
|
||||
};
|
||||
}).catch((err) => {
|
||||
this.validationMessage = {
|
||||
type: 'is-danger',
|
||||
text: err.message || this.$t('sqlSnippets.invalidQuery'),
|
||||
};
|
||||
}).finally(() => {
|
||||
this.isValidating = false;
|
||||
});
|
||||
},
|
||||
|
||||
fetchSnippets() {
|
||||
this.$api.getSQLSnippets().then((data) => {
|
||||
this.sqlSnippets = data;
|
||||
});
|
||||
},
|
||||
|
||||
updateSubscriberCount(query) {
|
||||
// Clear existing timer
|
||||
if (this.countDebounceTimer) {
|
||||
clearTimeout(this.countDebounceTimer);
|
||||
}
|
||||
|
||||
// Reset counts if no query
|
||||
if (!query || !query.trim()) {
|
||||
this.subscriberCount.found = 0;
|
||||
this.subscriberCount.error = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Set loading state
|
||||
this.subscriberCount.loading = true;
|
||||
this.subscriberCount.error = false;
|
||||
|
||||
// Debounce API call
|
||||
this.countDebounceTimer = setTimeout(() => {
|
||||
this.$api.countSQLSnippet({ query_sql: query }).then((response) => {
|
||||
this.subscriberCount.found = response.matched || 0;
|
||||
this.subscriberCount.total = response.total || 0;
|
||||
this.subscriberCount.loading = false;
|
||||
this.subscriberCount.error = false;
|
||||
}).catch(() => {
|
||||
this.subscriberCount.loading = false;
|
||||
this.subscriberCount.error = true;
|
||||
});
|
||||
}, 500); // 500ms debounce
|
||||
},
|
||||
|
||||
loadTotalSubscriberCount() {
|
||||
// Load total subscriber count on page load
|
||||
this.$api.countSQLSnippet({ query_sql: '' }).then((response) => {
|
||||
this.subscriberCount.total = response.total || 0;
|
||||
}).catch(() => {
|
||||
// Silently fail for total count loading
|
||||
});
|
||||
},
|
||||
|
||||
onLiveValidationChange() {
|
||||
// Save preference
|
||||
this.$utils.setPref('sqlSnippets.liveValidation', this.liveValidationEnabled);
|
||||
|
||||
// If enabling live validation and there's a query, trigger validation
|
||||
if (this.liveValidationEnabled && this.form.querySql.trim()) {
|
||||
this.updateSubscriberCount(this.form.querySql);
|
||||
} else if (!this.liveValidationEnabled) {
|
||||
// Reset counts when disabling
|
||||
this.subscriberCount.found = 0;
|
||||
this.subscriberCount.error = false;
|
||||
this.subscriberCount.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
// Watch for changes in the SQL query to update counts
|
||||
'form.querySql': {
|
||||
handler(newQuery) {
|
||||
if (this.liveValidationEnabled) {
|
||||
this.updateSubscriberCount(newQuery);
|
||||
} else {
|
||||
// Reset counts when live validation is disabled
|
||||
this.subscriberCount.found = 0;
|
||||
this.subscriberCount.error = false;
|
||||
this.subscriberCount.loading = false;
|
||||
}
|
||||
},
|
||||
immediate: false,
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.fetchSnippets();
|
||||
this.loadTotalSubscriberCount();
|
||||
// Load live validation preference
|
||||
this.liveValidationEnabled = this.$utils.getPref('sqlSnippets.liveValidation') !== false; // Default to true
|
||||
},
|
||||
|
|
|
@ -38,56 +38,22 @@
|
|||
</b-field>
|
||||
|
||||
<div v-if="isSearchAdvanced">
|
||||
<!-- SQL Snippets Autocomplete -->
|
||||
<div class="field">
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<b-input v-model="queryParams.queryExp" @keydown.native.enter="onAdvancedQueryEnter" type="textarea"
|
||||
ref="queryExp" placeholder="subscribers.name LIKE '%user%' or subscribers.status='blocklisted'"
|
||||
data-cy="query" />
|
||||
</div>
|
||||
<div class="control" style="position: relative;">
|
||||
<b-button @click="toggleSnippetsDropdown($event)" type="is-light" icon-left="code"
|
||||
:class="{ 'is-info': showSnippetsDropdown }"
|
||||
:disabled="sqlSnippets.length === 0"
|
||||
:title="`Snippets count: ${sqlSnippets.length}`">
|
||||
{{ $t('sqlSnippets.snippet') }} ({{ sqlSnippets.length }})
|
||||
</b-button>
|
||||
<!-- SQL Editor -->
|
||||
<SQLEditor
|
||||
v-model="queryParams.queryExp"
|
||||
@enter="onAdvancedQueryEnter"
|
||||
@snippet-selected="onSnippetSelected"
|
||||
placeholder="subscribers.name LIKE '%user%' or subscribers.status='blocklisted'"
|
||||
ref="sqlEditor"
|
||||
/>
|
||||
|
||||
<!-- SQL Counter -->
|
||||
<SQLCounter
|
||||
:query="queryParams.queryExp"
|
||||
:live-validation-enabled.sync="liveValidationEnabled"
|
||||
ref="sqlCounter"
|
||||
/>
|
||||
|
||||
<!-- SQL Snippets Dropdown -->
|
||||
<div v-if="showSnippetsDropdown" class="dropdown is-active" style="position: absolute; top: 100%; right: 0; width: 400px; z-index: 9999;">
|
||||
<div class="dropdown-menu" style="width: 100%;">
|
||||
<div class="dropdown-content">
|
||||
<div v-if="sqlSnippets.length === 0" class="dropdown-item has-text-grey">
|
||||
<em>{{ $t('globals.messages.emptyState') }}</em>
|
||||
</div>
|
||||
<a v-for="snippet in sqlSnippets" :key="snippet.id"
|
||||
@click="selectSQLSnippet(snippet)"
|
||||
@keydown.enter="selectSQLSnippet(snippet)"
|
||||
class="dropdown-item"
|
||||
tabindex="0"
|
||||
style="cursor: pointer;">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<b-icon icon="code" size="is-small" class="has-text-info" />
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div class="content">
|
||||
<p class="is-size-6 has-text-weight-semibold mb-1">{{ snippet.name }}</p>
|
||||
<p v-if="snippet.description" class="is-size-7 has-text-grey mb-2">{{ snippet.description }}</p>
|
||||
<p class="is-size-7 has-text-dark">
|
||||
<code class="has-background-light">{{ snippet.querySql || snippet.query_sql }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="is-size-6 has-text-grey">
|
||||
{{ $t('subscribers.advancedQueryHelp') }}.{{ ' ' }}
|
||||
<a href="https://listmonk.app/docs/querying-and-segmentation" target="_blank"
|
||||
|
@ -100,6 +66,11 @@
|
|||
{{
|
||||
$t('subscribers.query') }}
|
||||
</b-button>
|
||||
<b-button @click.prevent="showSaveSnippetModal" type="is-info" icon-left="code"
|
||||
:disabled="!queryParams.queryExp || queryParams.queryExp.trim() === ''"
|
||||
data-cy="btn-save-snippet">
|
||||
{{ $t('subscribers.saveAsSnippet') }}
|
||||
</b-button>
|
||||
<b-button @click.prevent="toggleAdvancedSearch" icon-left="cancel" data-cy="btn-query-reset">
|
||||
{{ $t('subscribers.reset') }}
|
||||
</b-button>
|
||||
|
@ -234,6 +205,61 @@
|
|||
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="850" @close="onFormClose">
|
||||
<subscriber-form :data="curItem" :is-editing="isEditing" @finished="querySubscribers" />
|
||||
</b-modal>
|
||||
|
||||
<!-- Save SQL Snippet modal -->
|
||||
<b-modal scroll="keep" :aria-modal="true" :active.sync="isSaveSnippetModalVisible" :width="900">
|
||||
<div class="modal-card" style="width: auto">
|
||||
<header class="modal-card-head">
|
||||
<h4 class="modal-card-title">
|
||||
<b-icon icon="code" size="is-small" class="mr-2" />
|
||||
{{ $t('subscribers.saveAsSnippet') }}
|
||||
</h4>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<b-field label-position="on-border" :type="saveSnippetForm.name ? '' : 'is-danger'">
|
||||
<template #label>
|
||||
<b-icon icon="tag-outline" size="is-small" class="mr-1" />
|
||||
{{ $t('globals.fields.name') }}
|
||||
</template>
|
||||
<b-input v-model="saveSnippetForm.name" :placeholder="$t('globals.fields.name')" required />
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<b-field label-position="on-border">
|
||||
<template #label>
|
||||
<b-icon icon="text" size="is-small" class="mr-1" />
|
||||
{{ $t('globals.fields.description') }}
|
||||
</template>
|
||||
<b-input v-model="saveSnippetForm.description" type="textarea" :placeholder="$t('globals.fields.description')" />
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<b-field label-position="on-border">
|
||||
<template #label>
|
||||
<b-icon icon="code" size="is-small" class="mr-1" />
|
||||
{{ $t('sqlSnippets.querySQL') }}
|
||||
</template>
|
||||
<b-input v-model="saveSnippetForm.query" type="textarea" readonly />
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
<b-button @click="closeSaveSnippetModal">{{ $t('globals.buttons.cancel') }}</b-button>
|
||||
<b-button @click="saveSQLSnippet" type="is-primary" :loading="loading.saveSnippet">
|
||||
{{ $t('globals.buttons.save') }}
|
||||
</b-button>
|
||||
</footer>
|
||||
</div>
|
||||
</b-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
@ -245,6 +271,8 @@ import { uris } from '../constants';
|
|||
import SubscriberBulkList from './SubscriberBulkList.vue';
|
||||
import SubscriberForm from './SubscriberForm.vue';
|
||||
import CopyText from '../components/CopyText.vue';
|
||||
import SQLEditor from '../components/SQLEditor.vue';
|
||||
import SQLCounter from '../components/SQLCounter.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
|
@ -252,6 +280,8 @@ export default Vue.extend({
|
|||
SubscriberBulkList,
|
||||
CopyText,
|
||||
EmptyPlaceholder,
|
||||
SQLEditor,
|
||||
SQLCounter,
|
||||
},
|
||||
|
||||
data() {
|
||||
|
@ -262,6 +292,7 @@ export default Vue.extend({
|
|||
isEditing: false,
|
||||
isFormVisible: false,
|
||||
isBulkListFormVisible: false,
|
||||
isSaveSnippetModalVisible: false,
|
||||
|
||||
// Table bulk row selection states.
|
||||
bulk: {
|
||||
|
@ -270,10 +301,14 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
queryInput: '',
|
||||
liveValidationEnabled: true,
|
||||
|
||||
// SQL snippets for autocomplete
|
||||
sqlSnippets: [],
|
||||
showSnippetsDropdown: false,
|
||||
// Save snippet form
|
||||
saveSnippetForm: {
|
||||
name: '',
|
||||
description: '',
|
||||
query: '',
|
||||
},
|
||||
|
||||
// Query params to filter the getSubscribers() API call.
|
||||
queryParams: {
|
||||
|
@ -297,90 +332,6 @@ export default Vue.extend({
|
|||
return lists.reduce((defVal, item) => (defVal + (item.subscriptionStatus !== 'unsubscribed' ? 1 : 0)), 0);
|
||||
},
|
||||
|
||||
// Load active SQL snippets for autocomplete
|
||||
loadSQLSnippets() {
|
||||
this.$api.getSQLSnippets({ is_active: true }).then((data) => {
|
||||
// API returns the array directly, not wrapped in results
|
||||
this.sqlSnippets = data || [];
|
||||
}).catch(() => {
|
||||
// Silently fail for SQL snippets loading - this is optional functionality
|
||||
this.sqlSnippets = [];
|
||||
});
|
||||
},
|
||||
|
||||
// Handle SQL snippet selection
|
||||
selectSQLSnippet(snippet) {
|
||||
if (snippet && (snippet.querySql || snippet.query_sql)) {
|
||||
const sqlQuery = snippet.querySql || snippet.query_sql;
|
||||
|
||||
// Simply replace the entire textarea content with the selected snippet
|
||||
this.queryParams.queryExp = sqlQuery;
|
||||
|
||||
// Close dropdown
|
||||
this.showSnippetsDropdown = false;
|
||||
|
||||
// Focus back to textarea and position cursor at the end
|
||||
this.$nextTick(() => {
|
||||
const textarea = this.$refs.queryExp;
|
||||
let actualTextarea = null;
|
||||
|
||||
if (textarea) {
|
||||
// Try different ways to get the actual textarea element
|
||||
if (textarea.$el && textarea.$el.querySelector) {
|
||||
actualTextarea = textarea.$el.querySelector('textarea');
|
||||
} else if (textarea.tagName === 'TEXTAREA') {
|
||||
actualTextarea = textarea;
|
||||
}
|
||||
|
||||
if (actualTextarea && typeof actualTextarea.focus === 'function') {
|
||||
actualTextarea.focus();
|
||||
if (typeof actualTextarea.setSelectionRange === 'function') {
|
||||
// Position cursor at the end of the inserted text
|
||||
const endPos = sqlQuery.length;
|
||||
actualTextarea.setSelectionRange(endPos, endPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle snippets dropdown
|
||||
toggleSnippetsDropdown(event) {
|
||||
// Prevent the blur event from immediately closing the dropdown
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
this.showSnippetsDropdown = !this.showSnippetsDropdown;
|
||||
|
||||
if (this.showSnippetsDropdown && this.sqlSnippets.length === 0) {
|
||||
this.loadSQLSnippets();
|
||||
}
|
||||
},
|
||||
|
||||
// Close snippets dropdown when clicking outside
|
||||
onSnippetsDropdownBlur() {
|
||||
// Use longer timeout to allow click on dropdown items to register first
|
||||
setTimeout(() => {
|
||||
this.showSnippetsDropdown = false;
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// Handle clicks outside the dropdown to close it
|
||||
handleClickOutside(event) {
|
||||
if (this.showSnippetsDropdown) {
|
||||
const dropdown = event.target.closest('.dropdown');
|
||||
const button = event.target.closest('button');
|
||||
|
||||
// Don't close if clicking on the dropdown or the button
|
||||
if (!dropdown && !button) {
|
||||
this.showSnippetsDropdown = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
toggleAdvancedSearch() {
|
||||
this.isSearchAdvanced = !this.isSearchAdvanced;
|
||||
this.queryParams.search = '';
|
||||
|
@ -412,7 +363,7 @@ export default Vue.extend({
|
|||
|
||||
// Toggling to advanced search.
|
||||
this.$nextTick(() => {
|
||||
this.$refs.queryExp.focus();
|
||||
this.$refs.sqlEditor.focus();
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -476,6 +427,15 @@ export default Vue.extend({
|
|||
}
|
||||
},
|
||||
|
||||
// Handle snippet selection - update counter immediately
|
||||
onSnippetSelected() {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.sqlCounter) {
|
||||
this.$refs.sqlCounter.updateImmediately();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onSubmit() {
|
||||
this.querySubscribers({ page: 1 });
|
||||
},
|
||||
|
@ -642,6 +602,45 @@ export default Vue.extend({
|
|||
this.$utils.toast(this.$t('subscribers.listChangeApplied'));
|
||||
});
|
||||
},
|
||||
|
||||
// Show save snippet modal
|
||||
showSaveSnippetModal() {
|
||||
this.saveSnippetForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
query: this.queryParams.queryExp,
|
||||
};
|
||||
this.isSaveSnippetModalVisible = true;
|
||||
},
|
||||
|
||||
// Close save snippet modal
|
||||
closeSaveSnippetModal() {
|
||||
this.isSaveSnippetModalVisible = false;
|
||||
},
|
||||
|
||||
// Save SQL snippet
|
||||
saveSQLSnippet() {
|
||||
if (!this.saveSnippetForm.name || !this.saveSnippetForm.query) {
|
||||
this.$utils.toast(this.$t('globals.messages.formIncomplete'), 'is-warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.$api.createSQLSnippet({
|
||||
name: this.saveSnippetForm.name,
|
||||
description: this.saveSnippetForm.description,
|
||||
query_sql: this.saveSnippetForm.query,
|
||||
is_active: true,
|
||||
}).then(() => {
|
||||
this.$utils.toast(this.$t('globals.messages.created', { name: this.saveSnippetForm.name }));
|
||||
this.closeSaveSnippetModal();
|
||||
// Refresh SQL snippets in the editor
|
||||
if (this.$refs.sqlEditor) {
|
||||
this.$refs.sqlEditor.refreshSnippets();
|
||||
}
|
||||
}).catch((e) => {
|
||||
this.$utils.toast(e.message, 'is-danger');
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
@ -665,12 +664,6 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
mounted() {
|
||||
// Load SQL snippets for autocomplete
|
||||
this.loadSQLSnippets();
|
||||
|
||||
// Add click outside listener for dropdown
|
||||
document.addEventListener('click', this.handleClickOutside);
|
||||
|
||||
if (this.$route.params.listID) {
|
||||
this.queryParams.listID = parseInt(this.$route.params.listID, 10);
|
||||
}
|
||||
|
@ -688,9 +681,5 @@ export default Vue.extend({
|
|||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
// Remove click outside listener
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -581,14 +581,17 @@
|
|||
"subscribers.listsPlaceholder": "Lists to subscribe to",
|
||||
"subscribers.manageLists": "Manage lists",
|
||||
"subscribers.markUnsubscribed": "Mark as unsubscribed",
|
||||
"subscribers.matchingSubscribers": "matching subscribers",
|
||||
"subscribers.newSubscriber": "New subscriber",
|
||||
"subscribers.numSelected": "{num} subscriber(s) selected",
|
||||
"subscribers.optinSubject": "Confirm subscription",
|
||||
"subscribers.outOfTotal": "out of {total} total",
|
||||
"subscribers.preconfirm": "Preconfirm subscriptions",
|
||||
"subscribers.preconfirmHelp": "Don't send opt-in e-mails and mark all list subscriptions as 'subscribed'.",
|
||||
"subscribers.query": "Query",
|
||||
"subscribers.queryPlaceholder": "E-mail or name",
|
||||
"subscribers.reset": "Reset",
|
||||
"subscribers.saveAsSnippet": "Save as Snippet",
|
||||
"subscribers.selectAll": "Select all {num}",
|
||||
"subscribers.sendOptinConfirm": "Send opt-in confirmation",
|
||||
"subscribers.sentOptinConfirm": "Opt-in confirmation sent",
|
||||
|
@ -655,10 +658,11 @@
|
|||
"sqlSnippets.description": "Create and manage reusable SQL query fragments for subscriber segmentation.",
|
||||
"sqlSnippets.emptyQuery": "Please enter a SQL query to validate.",
|
||||
"sqlSnippets.invalidQuery": "Invalid SQL query. Please check your syntax.",
|
||||
"sqlSnippets.liveValidation": "Live validation",
|
||||
"sqlSnippets.queryHelp": "Enter a WHERE clause condition for filtering subscribers (e.g., status = 'confirmed').",
|
||||
"sqlSnippets.queryPlaceholder": "subscribers.status = 'confirmed'",
|
||||
"sqlSnippets.querySQL": "SQL Query",
|
||||
"sqlSnippets.snippet": "SQL Snippet",
|
||||
"sqlSnippets.snippet": "Saved SQL snippets",
|
||||
"sqlSnippets.title": "SQL Snippets",
|
||||
"sqlSnippets.validQuery": "SQL query is valid!",
|
||||
"sqlSnippets.validate": "Validate Query"
|
||||
|
|
Loading…
Add table
Reference in a new issue