Extracting SQLCounter and SQLEditor components for enhanced SQL snippet functionality

This commit is contained in:
restyler 2025-06-29 01:14:44 +04:00
parent 2084fb1e94
commit c9b9adeff5
5 changed files with 507 additions and 323 deletions

View 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>

View 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>

View file

@ -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
},

View file

@ -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>

View file

@ -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"