mirror of
https://github.com/knadh/listmonk.git
synced 2025-10-11 15:57:08 +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') }}
|
{{ $t('sqlSnippets.queryHelp') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Live subscriber count -->
|
<!-- SQL Counter -->
|
||||||
<div class="mt-3">
|
<SQLCounter
|
||||||
<div class="level">
|
:query="form.querySql"
|
||||||
<div class="level-left">
|
:live-validation-enabled.sync="liveValidationEnabled"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-6">
|
<div class="column">
|
||||||
<b-field>
|
<b-field>
|
||||||
<b-checkbox v-model="form.is_active">
|
<b-checkbox v-model="form.is_active">
|
||||||
{{ $t('globals.fields.status') }}
|
{{ $t('globals.fields.status') }}
|
||||||
</b-checkbox>
|
</b-checkbox>
|
||||||
</b-field>
|
</b-field>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<footer class="modal-card-foot has-text-right">
|
<footer class="modal-card-foot has-text-right">
|
||||||
|
@ -223,27 +164,20 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import CodeEditor from '../components/CodeEditor.vue';
|
import CodeEditor from '../components/CodeEditor.vue';
|
||||||
|
import SQLCounter from '../components/SQLCounter.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'SqlSnippets',
|
name: 'SqlSnippets',
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
CodeEditor,
|
CodeEditor,
|
||||||
|
SQLCounter,
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
sqlSnippets: [],
|
sqlSnippets: [],
|
||||||
form: this.initForm(),
|
form: this.initForm(),
|
||||||
isValidating: false,
|
|
||||||
validationMessage: null,
|
|
||||||
subscriberCount: {
|
|
||||||
loading: false,
|
|
||||||
error: false,
|
|
||||||
found: 0,
|
|
||||||
total: 0,
|
|
||||||
},
|
|
||||||
countDebounceTimer: null,
|
|
||||||
liveValidationEnabled: true,
|
liveValidationEnabled: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -268,7 +202,6 @@ export default {
|
||||||
|
|
||||||
showForm(snippet = null) {
|
showForm(snippet = null) {
|
||||||
this.form = this.initForm();
|
this.form = this.initForm();
|
||||||
this.validationMessage = null;
|
|
||||||
|
|
||||||
if (snippet) {
|
if (snippet) {
|
||||||
// If editing existing snippet, fetch full data including querySql
|
// 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() {
|
fetchSnippets() {
|
||||||
this.$api.getSQLSnippets().then((data) => {
|
this.$api.getSQLSnippets().then((data) => {
|
||||||
this.sqlSnippets = 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() {
|
mounted() {
|
||||||
this.fetchSnippets();
|
this.fetchSnippets();
|
||||||
this.loadTotalSubscriberCount();
|
|
||||||
// Load live validation preference
|
// Load live validation preference
|
||||||
this.liveValidationEnabled = this.$utils.getPref('sqlSnippets.liveValidation') !== false; // Default to true
|
this.liveValidationEnabled = this.$utils.getPref('sqlSnippets.liveValidation') !== false; // Default to true
|
||||||
},
|
},
|
||||||
|
|
|
@ -38,56 +38,22 @@
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
<div v-if="isSearchAdvanced">
|
<div v-if="isSearchAdvanced">
|
||||||
<!-- SQL Snippets Autocomplete -->
|
<!-- SQL Editor -->
|
||||||
<div class="field">
|
<SQLEditor
|
||||||
<div class="field has-addons">
|
v-model="queryParams.queryExp"
|
||||||
<div class="control is-expanded">
|
@enter="onAdvancedQueryEnter"
|
||||||
<b-input v-model="queryParams.queryExp" @keydown.native.enter="onAdvancedQueryEnter" type="textarea"
|
@snippet-selected="onSnippetSelected"
|
||||||
ref="queryExp" placeholder="subscribers.name LIKE '%user%' or subscribers.status='blocklisted'"
|
placeholder="subscribers.name LIKE '%user%' or subscribers.status='blocklisted'"
|
||||||
data-cy="query" />
|
ref="sqlEditor"
|
||||||
</div>
|
/>
|
||||||
<div class="control" style="position: relative;">
|
|
||||||
<b-button @click="toggleSnippetsDropdown($event)" type="is-light" icon-left="code"
|
<!-- SQL Counter -->
|
||||||
:class="{ 'is-info': showSnippetsDropdown }"
|
<SQLCounter
|
||||||
:disabled="sqlSnippets.length === 0"
|
:query="queryParams.queryExp"
|
||||||
:title="`Snippets count: ${sqlSnippets.length}`">
|
:live-validation-enabled.sync="liveValidationEnabled"
|
||||||
{{ $t('sqlSnippets.snippet') }} ({{ sqlSnippets.length }})
|
ref="sqlCounter"
|
||||||
</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>
|
|
||||||
<span class="is-size-6 has-text-grey">
|
<span class="is-size-6 has-text-grey">
|
||||||
{{ $t('subscribers.advancedQueryHelp') }}.{{ ' ' }}
|
{{ $t('subscribers.advancedQueryHelp') }}.{{ ' ' }}
|
||||||
<a href="https://listmonk.app/docs/querying-and-segmentation" target="_blank"
|
<a href="https://listmonk.app/docs/querying-and-segmentation" target="_blank"
|
||||||
|
@ -100,6 +66,11 @@
|
||||||
{{
|
{{
|
||||||
$t('subscribers.query') }}
|
$t('subscribers.query') }}
|
||||||
</b-button>
|
</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">
|
<b-button @click.prevent="toggleAdvancedSearch" icon-left="cancel" data-cy="btn-query-reset">
|
||||||
{{ $t('subscribers.reset') }}
|
{{ $t('subscribers.reset') }}
|
||||||
</b-button>
|
</b-button>
|
||||||
|
@ -234,6 +205,61 @@
|
||||||
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="850" @close="onFormClose">
|
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="850" @close="onFormClose">
|
||||||
<subscriber-form :data="curItem" :is-editing="isEditing" @finished="querySubscribers" />
|
<subscriber-form :data="curItem" :is-editing="isEditing" @finished="querySubscribers" />
|
||||||
</b-modal>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -245,6 +271,8 @@ import { uris } from '../constants';
|
||||||
import SubscriberBulkList from './SubscriberBulkList.vue';
|
import SubscriberBulkList from './SubscriberBulkList.vue';
|
||||||
import SubscriberForm from './SubscriberForm.vue';
|
import SubscriberForm from './SubscriberForm.vue';
|
||||||
import CopyText from '../components/CopyText.vue';
|
import CopyText from '../components/CopyText.vue';
|
||||||
|
import SQLEditor from '../components/SQLEditor.vue';
|
||||||
|
import SQLCounter from '../components/SQLCounter.vue';
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
components: {
|
components: {
|
||||||
|
@ -252,6 +280,8 @@ export default Vue.extend({
|
||||||
SubscriberBulkList,
|
SubscriberBulkList,
|
||||||
CopyText,
|
CopyText,
|
||||||
EmptyPlaceholder,
|
EmptyPlaceholder,
|
||||||
|
SQLEditor,
|
||||||
|
SQLCounter,
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
@ -262,6 +292,7 @@ export default Vue.extend({
|
||||||
isEditing: false,
|
isEditing: false,
|
||||||
isFormVisible: false,
|
isFormVisible: false,
|
||||||
isBulkListFormVisible: false,
|
isBulkListFormVisible: false,
|
||||||
|
isSaveSnippetModalVisible: false,
|
||||||
|
|
||||||
// Table bulk row selection states.
|
// Table bulk row selection states.
|
||||||
bulk: {
|
bulk: {
|
||||||
|
@ -270,10 +301,14 @@ export default Vue.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
queryInput: '',
|
queryInput: '',
|
||||||
|
liveValidationEnabled: true,
|
||||||
|
|
||||||
// SQL snippets for autocomplete
|
// Save snippet form
|
||||||
sqlSnippets: [],
|
saveSnippetForm: {
|
||||||
showSnippetsDropdown: false,
|
name: '',
|
||||||
|
description: '',
|
||||||
|
query: '',
|
||||||
|
},
|
||||||
|
|
||||||
// Query params to filter the getSubscribers() API call.
|
// Query params to filter the getSubscribers() API call.
|
||||||
queryParams: {
|
queryParams: {
|
||||||
|
@ -297,90 +332,6 @@ export default Vue.extend({
|
||||||
return lists.reduce((defVal, item) => (defVal + (item.subscriptionStatus !== 'unsubscribed' ? 1 : 0)), 0);
|
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() {
|
toggleAdvancedSearch() {
|
||||||
this.isSearchAdvanced = !this.isSearchAdvanced;
|
this.isSearchAdvanced = !this.isSearchAdvanced;
|
||||||
this.queryParams.search = '';
|
this.queryParams.search = '';
|
||||||
|
@ -412,7 +363,7 @@ export default Vue.extend({
|
||||||
|
|
||||||
// Toggling to advanced search.
|
// Toggling to advanced search.
|
||||||
this.$nextTick(() => {
|
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() {
|
onSubmit() {
|
||||||
this.querySubscribers({ page: 1 });
|
this.querySubscribers({ page: 1 });
|
||||||
},
|
},
|
||||||
|
@ -642,6 +602,45 @@ export default Vue.extend({
|
||||||
this.$utils.toast(this.$t('subscribers.listChangeApplied'));
|
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: {
|
computed: {
|
||||||
|
@ -665,12 +664,6 @@ export default Vue.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
// Load SQL snippets for autocomplete
|
|
||||||
this.loadSQLSnippets();
|
|
||||||
|
|
||||||
// Add click outside listener for dropdown
|
|
||||||
document.addEventListener('click', this.handleClickOutside);
|
|
||||||
|
|
||||||
if (this.$route.params.listID) {
|
if (this.$route.params.listID) {
|
||||||
this.queryParams.listID = parseInt(this.$route.params.listID, 10);
|
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>
|
</script>
|
||||||
|
|
|
@ -581,14 +581,17 @@
|
||||||
"subscribers.listsPlaceholder": "Lists to subscribe to",
|
"subscribers.listsPlaceholder": "Lists to subscribe to",
|
||||||
"subscribers.manageLists": "Manage lists",
|
"subscribers.manageLists": "Manage lists",
|
||||||
"subscribers.markUnsubscribed": "Mark as unsubscribed",
|
"subscribers.markUnsubscribed": "Mark as unsubscribed",
|
||||||
|
"subscribers.matchingSubscribers": "matching subscribers",
|
||||||
"subscribers.newSubscriber": "New subscriber",
|
"subscribers.newSubscriber": "New subscriber",
|
||||||
"subscribers.numSelected": "{num} subscriber(s) selected",
|
"subscribers.numSelected": "{num} subscriber(s) selected",
|
||||||
"subscribers.optinSubject": "Confirm subscription",
|
"subscribers.optinSubject": "Confirm subscription",
|
||||||
|
"subscribers.outOfTotal": "out of {total} total",
|
||||||
"subscribers.preconfirm": "Preconfirm subscriptions",
|
"subscribers.preconfirm": "Preconfirm subscriptions",
|
||||||
"subscribers.preconfirmHelp": "Don't send opt-in e-mails and mark all list subscriptions as 'subscribed'.",
|
"subscribers.preconfirmHelp": "Don't send opt-in e-mails and mark all list subscriptions as 'subscribed'.",
|
||||||
"subscribers.query": "Query",
|
"subscribers.query": "Query",
|
||||||
"subscribers.queryPlaceholder": "E-mail or name",
|
"subscribers.queryPlaceholder": "E-mail or name",
|
||||||
"subscribers.reset": "Reset",
|
"subscribers.reset": "Reset",
|
||||||
|
"subscribers.saveAsSnippet": "Save as Snippet",
|
||||||
"subscribers.selectAll": "Select all {num}",
|
"subscribers.selectAll": "Select all {num}",
|
||||||
"subscribers.sendOptinConfirm": "Send opt-in confirmation",
|
"subscribers.sendOptinConfirm": "Send opt-in confirmation",
|
||||||
"subscribers.sentOptinConfirm": "Opt-in confirmation sent",
|
"subscribers.sentOptinConfirm": "Opt-in confirmation sent",
|
||||||
|
@ -655,10 +658,11 @@
|
||||||
"sqlSnippets.description": "Create and manage reusable SQL query fragments for subscriber segmentation.",
|
"sqlSnippets.description": "Create and manage reusable SQL query fragments for subscriber segmentation.",
|
||||||
"sqlSnippets.emptyQuery": "Please enter a SQL query to validate.",
|
"sqlSnippets.emptyQuery": "Please enter a SQL query to validate.",
|
||||||
"sqlSnippets.invalidQuery": "Invalid SQL query. Please check your syntax.",
|
"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.queryHelp": "Enter a WHERE clause condition for filtering subscribers (e.g., status = 'confirmed').",
|
||||||
"sqlSnippets.queryPlaceholder": "subscribers.status = 'confirmed'",
|
"sqlSnippets.queryPlaceholder": "subscribers.status = 'confirmed'",
|
||||||
"sqlSnippets.querySQL": "SQL Query",
|
"sqlSnippets.querySQL": "SQL Query",
|
||||||
"sqlSnippets.snippet": "SQL Snippet",
|
"sqlSnippets.snippet": "Saved SQL snippets",
|
||||||
"sqlSnippets.title": "SQL Snippets",
|
"sqlSnippets.title": "SQL Snippets",
|
||||||
"sqlSnippets.validQuery": "SQL query is valid!",
|
"sqlSnippets.validQuery": "SQL query is valid!",
|
||||||
"sqlSnippets.validate": "Validate Query"
|
"sqlSnippets.validate": "Validate Query"
|
||||||
|
|
Loading…
Add table
Reference in a new issue