sage-rails 0.0.7 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -1
- data/app/assets/javascripts/sage/controllers/clipboard_controller.js +26 -0
- data/app/assets/javascripts/sage/controllers/dashboard_controller.js +132 -0
- data/app/assets/javascripts/sage/controllers/reverse_infinite_scroll_controller.js +146 -0
- data/app/assets/javascripts/sage/controllers/search_controller.js +47 -0
- data/app/assets/javascripts/sage/controllers/select_controller.js +215 -0
- data/app/assets/javascripts/sage/controllers/variables_controller.js +122 -0
- data/app/assets/javascripts/sage.js +21 -0
- data/lib/generators/sage/install/install_generator.rb +11 -33
- data/lib/sage/engine.rb +2 -2
- data/lib/sage/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0d4771d99542d22d3e3665bd1d633decc10a4b9246fc867d48bb9b6c63598060
|
4
|
+
data.tar.gz: bf330ca38aff34abb0a3df19d09d937e2235d659a98f30143082569c1aea9aa3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: db485b33ee21c595f40eb29e9d0e9aa62514ddd67db154ee0022757934ceedd7382183bef532f3566a4c13f6ec5d0a3bf9a2f44d6fd47835c665888752fd9d80
|
7
|
+
data.tar.gz: 51bcfe5e82dc8350c5c6294b75b1859e9e0bc6f24ed175d06c950a849c57b416fd86ad7956940c2a498c7cc10d1883d758382345e64e2161762cee62a7cc2d01
|
data/README.md
CHANGED
@@ -10,6 +10,8 @@ Sage is a Rails engine built on top of the excellent [Blazer](https://github.com
|
|
10
10
|
|
11
11
|
## Requirements
|
12
12
|
|
13
|
+
Rails 7.1+ is recommended, compatability with older versions not guaranteed.
|
14
|
+
|
13
15
|
Sage relies on `turbo-rails` and `stimulus-rails` to render the dashboard. These gems should already be included in most modern Rails applications. If not, add them to your Gemfile:
|
14
16
|
|
15
17
|
```ruby
|
@@ -45,7 +47,6 @@ This generator will:
|
|
45
47
|
- Mount Sage at `/sage` in your routes
|
46
48
|
- Create an initializer at `config/initializers/sage.rb`
|
47
49
|
- Set up database migrations for message storage
|
48
|
-
- Configure JavaScript and CSS dependencies
|
49
50
|
|
50
51
|
After installation, run the migrations:
|
51
52
|
```bash
|
@@ -0,0 +1,26 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static values = { text: String }
|
5
|
+
|
6
|
+
connect() {
|
7
|
+
console.log("Clipboard controller connected");
|
8
|
+
}
|
9
|
+
|
10
|
+
copy(event) {
|
11
|
+
event.preventDefault()
|
12
|
+
|
13
|
+
navigator.clipboard.writeText(this.textValue).then(() => {
|
14
|
+
const originalText = event.target.textContent
|
15
|
+
event.target.textContent = "Copied!"
|
16
|
+
event.target.classList.add("btn-success")
|
17
|
+
|
18
|
+
setTimeout(() => {
|
19
|
+
event.target.textContent = originalText
|
20
|
+
event.target.classList.remove("btn-success")
|
21
|
+
}, 2000)
|
22
|
+
}).catch(err => {
|
23
|
+
console.error('Failed to copy text: ', err)
|
24
|
+
})
|
25
|
+
}
|
26
|
+
}
|
@@ -0,0 +1,132 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
|
3
|
+
// Connects to data-controller="dashboard"
|
4
|
+
export default class extends Controller {
|
5
|
+
static targets = ["queryList", "queryTemplate"];
|
6
|
+
static values = { queries: Array };
|
7
|
+
|
8
|
+
connect() {
|
9
|
+
console.log("Dashboard controller connected");
|
10
|
+
console.log("Queries value:", this.queriesValue);
|
11
|
+
|
12
|
+
// Parse the queries value if it's a string
|
13
|
+
const queriesData = typeof this.queriesValue === 'string' ?
|
14
|
+
JSON.parse(this.queriesValue) : this.queriesValue;
|
15
|
+
this.selectedQueries = Array.isArray(queriesData) ? [...queriesData] : [];
|
16
|
+
|
17
|
+
console.log("Selected queries:", this.selectedQueries);
|
18
|
+
|
19
|
+
this.render();
|
20
|
+
this.setupSortable();
|
21
|
+
}
|
22
|
+
|
23
|
+
addQuery(event) {
|
24
|
+
const { value, text } = event.detail;
|
25
|
+
|
26
|
+
// Check for duplicates and remove if found
|
27
|
+
const existingIndex = this.selectedQueries.findIndex(q => q.id == value);
|
28
|
+
if (existingIndex !== -1) {
|
29
|
+
this.selectedQueries.splice(existingIndex, 1);
|
30
|
+
}
|
31
|
+
|
32
|
+
// Add the new query
|
33
|
+
this.selectedQueries.push({ id: value, name: text });
|
34
|
+
this.render();
|
35
|
+
}
|
36
|
+
|
37
|
+
removeQuery(event) {
|
38
|
+
const index = parseInt(event.params.index);
|
39
|
+
this.selectedQueries.splice(index, 1);
|
40
|
+
this.render();
|
41
|
+
}
|
42
|
+
|
43
|
+
render() {
|
44
|
+
// Check if the target exists
|
45
|
+
if (!this.hasQueryListTarget) {
|
46
|
+
console.error("queryList target not found");
|
47
|
+
return;
|
48
|
+
}
|
49
|
+
|
50
|
+
if (this.selectedQueries.length === 0) {
|
51
|
+
this.queryListTarget.style.display = 'none';
|
52
|
+
return;
|
53
|
+
}
|
54
|
+
|
55
|
+
this.queryListTarget.style.display = 'block';
|
56
|
+
let queriesContainer = this.queryListTarget.querySelector('#queries');
|
57
|
+
|
58
|
+
// If the container doesn't exist, create it
|
59
|
+
if (!queriesContainer) {
|
60
|
+
queriesContainer = document.createElement('div');
|
61
|
+
queriesContainer.id = 'queries';
|
62
|
+
queriesContainer.style.display = 'flex';
|
63
|
+
queriesContainer.style.flexDirection = 'column';
|
64
|
+
queriesContainer.style.gap = '8px';
|
65
|
+
this.queryListTarget.appendChild(queriesContainer);
|
66
|
+
}
|
67
|
+
|
68
|
+
queriesContainer.innerHTML = '';
|
69
|
+
|
70
|
+
this.selectedQueries.forEach((query, index) => {
|
71
|
+
// Create a plain div without any Beer CSS classes
|
72
|
+
const item = document.createElement('div');
|
73
|
+
// Remove ALL classes and use inline styles only
|
74
|
+
item.style.cssText = `
|
75
|
+
display: flex !important;
|
76
|
+
align-items: center !important;
|
77
|
+
width: 100% !important;
|
78
|
+
padding: 12px !important;
|
79
|
+
margin-bottom: 8px !important;
|
80
|
+
background-color: #f5f5f5 !important;
|
81
|
+
border-radius: 8px !important;
|
82
|
+
opacity: 1 !important;
|
83
|
+
filter: none !important;
|
84
|
+
`;
|
85
|
+
|
86
|
+
// Use a Material Icons checkbox instead of HTML input
|
87
|
+
const checkIcon = document.createElement('i');
|
88
|
+
checkIcon.className = 'material-icons';
|
89
|
+
checkIcon.textContent = 'check_box';
|
90
|
+
checkIcon.style.cssText = 'color: #6750A4 !important; margin-right: 12px !important; font-size: 24px !important;';
|
91
|
+
|
92
|
+
const text = document.createElement('strong'); // Use strong tag
|
93
|
+
text.textContent = query.name;
|
94
|
+
text.style.cssText = 'flex: 1 !important; color: #000000 !important; opacity: 1 !important; filter: none !important;';
|
95
|
+
|
96
|
+
const closeBtn = document.createElement('i');
|
97
|
+
closeBtn.className = 'material-icons';
|
98
|
+
closeBtn.textContent = 'close';
|
99
|
+
closeBtn.style.cssText = 'cursor: pointer !important; color: #000000 !important; opacity: 1 !important; font-size: 20px !important;';
|
100
|
+
closeBtn.setAttribute('data-action', 'click->sage--dashboard#removeQuery');
|
101
|
+
closeBtn.setAttribute('data-sage--dashboard-index-param', index);
|
102
|
+
|
103
|
+
const hiddenInput = document.createElement('input');
|
104
|
+
hiddenInput.type = 'hidden';
|
105
|
+
hiddenInput.name = 'query_ids[]';
|
106
|
+
hiddenInput.value = query.id;
|
107
|
+
|
108
|
+
item.appendChild(checkIcon);
|
109
|
+
item.appendChild(text);
|
110
|
+
item.appendChild(closeBtn);
|
111
|
+
item.appendChild(hiddenInput);
|
112
|
+
|
113
|
+
queriesContainer.appendChild(item);
|
114
|
+
});
|
115
|
+
}
|
116
|
+
|
117
|
+
setupSortable() {
|
118
|
+
if (typeof Sortable !== 'undefined') {
|
119
|
+
const queriesContainer = this.queryListTarget.querySelector('#queries');
|
120
|
+
if (queriesContainer) {
|
121
|
+
Sortable.create(queriesContainer, {
|
122
|
+
onEnd: (e) => {
|
123
|
+
// Move the item in our array to match the new position
|
124
|
+
const movedItem = this.selectedQueries.splice(e.oldIndex, 1)[0];
|
125
|
+
this.selectedQueries.splice(e.newIndex, 0, movedItem);
|
126
|
+
this.render();
|
127
|
+
}
|
128
|
+
});
|
129
|
+
}
|
130
|
+
}
|
131
|
+
}
|
132
|
+
}
|
@@ -0,0 +1,146 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
|
3
|
+
console.log("Reverse infinite scroll controller file loaded!");
|
4
|
+
|
5
|
+
export default class extends Controller {
|
6
|
+
static targets = ["entries", "pagination"];
|
7
|
+
static values = {
|
8
|
+
url: String,
|
9
|
+
page: Number,
|
10
|
+
loading: Boolean,
|
11
|
+
};
|
12
|
+
|
13
|
+
initialize() {
|
14
|
+
console.log("Reverse infinite scroll controller initialized");
|
15
|
+
this.intersectionObserver = new IntersectionObserver(
|
16
|
+
(entries) => {
|
17
|
+
entries.forEach((entry) => {
|
18
|
+
if (entry.isIntersecting) {
|
19
|
+
console.log("Pagination target is visible - loading older messages");
|
20
|
+
this.loadOlder();
|
21
|
+
}
|
22
|
+
});
|
23
|
+
},
|
24
|
+
{
|
25
|
+
rootMargin: "50px 0px 50px 0px",
|
26
|
+
threshold: 0.1,
|
27
|
+
}
|
28
|
+
);
|
29
|
+
}
|
30
|
+
|
31
|
+
connect() {
|
32
|
+
console.log("Reverse infinite scroll controller connected");
|
33
|
+
console.log("Initial page value:", this.pageValue);
|
34
|
+
|
35
|
+
if (this.hasPaginationTarget) {
|
36
|
+
console.log("Found pagination target, observing...");
|
37
|
+
this.intersectionObserver.observe(this.paginationTarget);
|
38
|
+
} else {
|
39
|
+
console.log("No pagination target found");
|
40
|
+
}
|
41
|
+
}
|
42
|
+
|
43
|
+
paginationTargetConnected(target) {
|
44
|
+
console.log("Pagination target connected dynamically, observing...");
|
45
|
+
// Add a delay to allow scroll-to-bottom to happen first
|
46
|
+
setTimeout(() => {
|
47
|
+
console.log("Starting to observe pagination target after delay");
|
48
|
+
this.intersectionObserver.observe(target);
|
49
|
+
}, 1000);
|
50
|
+
}
|
51
|
+
|
52
|
+
paginationTargetDisconnected(target) {
|
53
|
+
console.log("Pagination target disconnected, stop observing...");
|
54
|
+
this.intersectionObserver.unobserve(target);
|
55
|
+
}
|
56
|
+
|
57
|
+
disconnect() {
|
58
|
+
console.log("Reverse infinite scroll controller disconnected");
|
59
|
+
this.intersectionObserver.disconnect();
|
60
|
+
}
|
61
|
+
|
62
|
+
buildUrl(nextPage) {
|
63
|
+
const url = new URL(this.urlValue, window.location.origin);
|
64
|
+
url.searchParams.set("page", nextPage);
|
65
|
+
return url.toString();
|
66
|
+
}
|
67
|
+
|
68
|
+
async loadOlder() {
|
69
|
+
if (this.loadingValue) {
|
70
|
+
console.log("Already loading, skipping...");
|
71
|
+
return;
|
72
|
+
}
|
73
|
+
|
74
|
+
console.log("Loading older messages...");
|
75
|
+
console.log("Current pageValue:", this.pageValue);
|
76
|
+
this.loadingValue = true;
|
77
|
+
const nextPage = this.pageValue + 1;
|
78
|
+
console.log("Requesting page:", nextPage);
|
79
|
+
|
80
|
+
// Store current scroll position
|
81
|
+
const oldScrollHeight = this.element.scrollHeight;
|
82
|
+
const oldScrollTop = this.element.scrollTop;
|
83
|
+
|
84
|
+
try {
|
85
|
+
const url = this.buildUrl(nextPage);
|
86
|
+
console.log("Fetching URL:", url);
|
87
|
+
|
88
|
+
const response = await fetch(url, {
|
89
|
+
headers: {
|
90
|
+
Accept: "text/vnd.turbo-stream.html",
|
91
|
+
},
|
92
|
+
});
|
93
|
+
|
94
|
+
if (!response.ok) {
|
95
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
96
|
+
}
|
97
|
+
|
98
|
+
const html = await response.text();
|
99
|
+
console.log("Received response for page", nextPage);
|
100
|
+
|
101
|
+
// Parse the turbo-stream content
|
102
|
+
const parser = new DOMParser();
|
103
|
+
const doc = parser.parseFromString(html, "text/html");
|
104
|
+
const template = doc.querySelector("template");
|
105
|
+
|
106
|
+
if (template) {
|
107
|
+
// Remove the current pagination target before appending new content
|
108
|
+
if (this.hasPaginationTarget) {
|
109
|
+
this.paginationTarget.remove();
|
110
|
+
}
|
111
|
+
|
112
|
+
// Manually prepend the template content (for reverse infinite scroll)
|
113
|
+
this.entriesTarget.insertAdjacentHTML("afterbegin", template.innerHTML);
|
114
|
+
this.pageValue = nextPage;
|
115
|
+
console.log(`Updated to page ${nextPage}`);
|
116
|
+
|
117
|
+
// Maintain scroll position (prevent jumping to top)
|
118
|
+
setTimeout(() => {
|
119
|
+
const newScrollHeight = this.element.scrollHeight;
|
120
|
+
const heightDifference = newScrollHeight - oldScrollHeight;
|
121
|
+
this.element.scrollTop = oldScrollTop + heightDifference;
|
122
|
+
|
123
|
+
console.log("Scroll position updated:", {
|
124
|
+
oldScrollHeight,
|
125
|
+
newScrollHeight,
|
126
|
+
heightDifference,
|
127
|
+
newScrollTop: this.element.scrollTop
|
128
|
+
});
|
129
|
+
|
130
|
+
// Set up observer for new pagination target if it exists
|
131
|
+
if (this.hasPaginationTarget) {
|
132
|
+
console.log("Found new pagination target, observing...");
|
133
|
+
this.intersectionObserver.observe(this.paginationTarget);
|
134
|
+
} else {
|
135
|
+
console.log("No more pages to load");
|
136
|
+
}
|
137
|
+
}, 50);
|
138
|
+
}
|
139
|
+
} catch (error) {
|
140
|
+
console.error("Error loading older messages:", error);
|
141
|
+
console.log("Staying on page", this.pageValue, "due to error");
|
142
|
+
} finally {
|
143
|
+
this.loadingValue = false;
|
144
|
+
}
|
145
|
+
}
|
146
|
+
}
|
@@ -0,0 +1,47 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
import debounce from "debounce";
|
3
|
+
|
4
|
+
// Connects to data-controller="search"
|
5
|
+
export default class extends Controller {
|
6
|
+
initialize() {
|
7
|
+
this.submit = debounce(this.submit.bind(this), 300);
|
8
|
+
this.searchInput = null;
|
9
|
+
}
|
10
|
+
|
11
|
+
connect() {
|
12
|
+
console.log("Search controller initialized");
|
13
|
+
// Find the search input
|
14
|
+
this.searchInput = this.element.querySelector('input[type="search"]');
|
15
|
+
|
16
|
+
if (this.searchInput) {
|
17
|
+
// Focus the input
|
18
|
+
if (this.searchInput.value.length > 0) {
|
19
|
+
this.searchInput.focus();
|
20
|
+
}
|
21
|
+
|
22
|
+
// If we have a stored cursor position, restore it
|
23
|
+
if (this.cursorPosition !== undefined) {
|
24
|
+
this.searchInput.setSelectionRange(
|
25
|
+
this.cursorPosition,
|
26
|
+
this.cursorPosition
|
27
|
+
);
|
28
|
+
// Clear the stored position after using it
|
29
|
+
this.cursorPosition = undefined;
|
30
|
+
} else {
|
31
|
+
// If no stored position, place cursor at the end
|
32
|
+
const length = this.searchInput.value.length;
|
33
|
+
this.searchInput.setSelectionRange(length, length);
|
34
|
+
}
|
35
|
+
}
|
36
|
+
}
|
37
|
+
|
38
|
+
submit(event) {
|
39
|
+
// Store the cursor position before submitting
|
40
|
+
if (this.searchInput) {
|
41
|
+
this.cursorPosition = this.searchInput.selectionStart;
|
42
|
+
}
|
43
|
+
|
44
|
+
this.element.requestSubmit();
|
45
|
+
}
|
46
|
+
}
|
47
|
+
|
@@ -0,0 +1,215 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
|
3
|
+
// Connects to data-controller="select"
|
4
|
+
export default class extends Controller {
|
5
|
+
static targets = ["input", "dropdown", "option", "hidden"];
|
6
|
+
static values = {
|
7
|
+
options: Array,
|
8
|
+
placeholder: String,
|
9
|
+
selected: String,
|
10
|
+
maxOptions: { type: Number, default: 100 }
|
11
|
+
};
|
12
|
+
|
13
|
+
connect() {
|
14
|
+
this.selectedValue = this.selectedValue || "";
|
15
|
+
|
16
|
+
// Parse options if they're a string
|
17
|
+
let options = this.optionsValue;
|
18
|
+
if (typeof options === 'string') {
|
19
|
+
try {
|
20
|
+
options = JSON.parse(options);
|
21
|
+
} catch (e) {
|
22
|
+
console.error("Failed to parse options:", e);
|
23
|
+
options = [];
|
24
|
+
}
|
25
|
+
}
|
26
|
+
|
27
|
+
this.filteredOptions = Array.isArray(options) ? options.slice(0, this.maxOptionsValue) : [];
|
28
|
+
|
29
|
+
// If there's a pre-selected value from the data attribute, set it
|
30
|
+
if (this.hasSelectedValue && this.selectedValue) {
|
31
|
+
const selectedOption = this.filteredOptions.find(opt => opt.value == this.selectedValue);
|
32
|
+
if (selectedOption) {
|
33
|
+
this.inputTarget.value = selectedOption.text;
|
34
|
+
if (this.hasHiddenTarget) {
|
35
|
+
this.hiddenTarget.value = selectedOption.value;
|
36
|
+
}
|
37
|
+
}
|
38
|
+
}
|
39
|
+
|
40
|
+
this.render();
|
41
|
+
this.setupEventListeners();
|
42
|
+
}
|
43
|
+
|
44
|
+
setupEventListeners() {
|
45
|
+
// Close dropdown when clicking outside
|
46
|
+
document.addEventListener('click', this.handleOutsideClick.bind(this));
|
47
|
+
|
48
|
+
// Handle keyboard navigation
|
49
|
+
this.inputTarget.addEventListener('keydown', this.handleKeydown.bind(this));
|
50
|
+
}
|
51
|
+
|
52
|
+
disconnect() {
|
53
|
+
document.removeEventListener('click', this.handleOutsideClick.bind(this));
|
54
|
+
}
|
55
|
+
|
56
|
+
handleOutsideClick(event) {
|
57
|
+
if (!this.element.contains(event.target)) {
|
58
|
+
this.closeDropdown();
|
59
|
+
}
|
60
|
+
}
|
61
|
+
|
62
|
+
handleKeydown(event) {
|
63
|
+
const dropdown = this.dropdownTarget;
|
64
|
+
const options = this.optionTargets;
|
65
|
+
const activeOption = dropdown.querySelector('.active');
|
66
|
+
|
67
|
+
switch(event.key) {
|
68
|
+
case 'ArrowDown':
|
69
|
+
event.preventDefault();
|
70
|
+
this.navigateOptions(options, activeOption, 1);
|
71
|
+
break;
|
72
|
+
case 'ArrowUp':
|
73
|
+
event.preventDefault();
|
74
|
+
this.navigateOptions(options, activeOption, -1);
|
75
|
+
break;
|
76
|
+
case 'Enter':
|
77
|
+
event.preventDefault();
|
78
|
+
if (activeOption) {
|
79
|
+
this.selectOption(activeOption.dataset.value, activeOption.textContent);
|
80
|
+
}
|
81
|
+
break;
|
82
|
+
case 'Escape':
|
83
|
+
this.closeDropdown();
|
84
|
+
break;
|
85
|
+
}
|
86
|
+
}
|
87
|
+
|
88
|
+
navigateOptions(options, activeOption, direction) {
|
89
|
+
let currentIndex = activeOption ? Array.from(options).indexOf(activeOption) : -1;
|
90
|
+
let nextIndex = currentIndex + direction;
|
91
|
+
|
92
|
+
if (nextIndex < 0) nextIndex = options.length - 1;
|
93
|
+
if (nextIndex >= options.length) nextIndex = 0;
|
94
|
+
|
95
|
+
// Remove active class from all options
|
96
|
+
options.forEach(option => option.classList.remove('active'));
|
97
|
+
|
98
|
+
// Add active class to next option
|
99
|
+
if (options[nextIndex]) {
|
100
|
+
options[nextIndex].classList.add('active');
|
101
|
+
options[nextIndex].scrollIntoView({ block: 'nearest' });
|
102
|
+
}
|
103
|
+
}
|
104
|
+
|
105
|
+
search(event) {
|
106
|
+
const query = event.target.value.toLowerCase().trim();
|
107
|
+
|
108
|
+
// Parse options properly
|
109
|
+
let options = this.optionsValue;
|
110
|
+
if (typeof options === 'string') {
|
111
|
+
try {
|
112
|
+
options = JSON.parse(options);
|
113
|
+
} catch (e) {
|
114
|
+
options = [];
|
115
|
+
}
|
116
|
+
}
|
117
|
+
options = Array.isArray(options) ? options : [];
|
118
|
+
|
119
|
+
if (query === '' || query === ' ') {
|
120
|
+
this.filteredOptions = options.slice(0, this.maxOptionsValue);
|
121
|
+
} else {
|
122
|
+
this.filteredOptions = options
|
123
|
+
.filter(option => option.text.toLowerCase().includes(query))
|
124
|
+
.slice(0, this.maxOptionsValue);
|
125
|
+
}
|
126
|
+
|
127
|
+
this.renderOptions();
|
128
|
+
this.openDropdown();
|
129
|
+
}
|
130
|
+
|
131
|
+
selectOption(value, text) {
|
132
|
+
// Update the input to show selected text
|
133
|
+
this.inputTarget.value = text;
|
134
|
+
this.selectedValue = value;
|
135
|
+
|
136
|
+
// Update hidden field if present
|
137
|
+
if (this.hasHiddenTarget) {
|
138
|
+
this.hiddenTarget.value = value;
|
139
|
+
}
|
140
|
+
|
141
|
+
// Dispatch custom event for external handling
|
142
|
+
const selectEvent = new CustomEvent('select:change', {
|
143
|
+
detail: { value: value, text: text },
|
144
|
+
bubbles: true
|
145
|
+
});
|
146
|
+
this.element.dispatchEvent(selectEvent);
|
147
|
+
|
148
|
+
this.closeDropdown();
|
149
|
+
}
|
150
|
+
|
151
|
+
openDropdown() {
|
152
|
+
this.dropdownTarget.classList.remove('hidden');
|
153
|
+
this.dropdownTarget.classList.add('visible');
|
154
|
+
}
|
155
|
+
|
156
|
+
closeDropdown() {
|
157
|
+
this.dropdownTarget.classList.remove('visible');
|
158
|
+
this.dropdownTarget.classList.add('hidden');
|
159
|
+
}
|
160
|
+
|
161
|
+
focus() {
|
162
|
+
// Reset search to show all options when focusing
|
163
|
+
const currentValue = this.inputTarget.value.trim();
|
164
|
+
if (currentValue === '' || currentValue === ' ') {
|
165
|
+
// Parse options properly
|
166
|
+
let options = this.optionsValue;
|
167
|
+
if (typeof options === 'string') {
|
168
|
+
try {
|
169
|
+
options = JSON.parse(options);
|
170
|
+
} catch (e) {
|
171
|
+
options = [];
|
172
|
+
}
|
173
|
+
}
|
174
|
+
this.filteredOptions = Array.isArray(options) ? options.slice(0, this.maxOptionsValue) : [];
|
175
|
+
this.renderOptions();
|
176
|
+
}
|
177
|
+
this.openDropdown();
|
178
|
+
}
|
179
|
+
|
180
|
+
blur() {
|
181
|
+
// Delay hiding to allow option clicks
|
182
|
+
setTimeout(() => {
|
183
|
+
this.closeDropdown();
|
184
|
+
}, 150);
|
185
|
+
}
|
186
|
+
|
187
|
+
render() {
|
188
|
+
// Don't set placeholder for Beer CSS floating labels
|
189
|
+
this.renderOptions();
|
190
|
+
}
|
191
|
+
|
192
|
+
renderOptions() {
|
193
|
+
this.dropdownTarget.innerHTML = '';
|
194
|
+
|
195
|
+
if (this.filteredOptions.length === 0) {
|
196
|
+
const noResults = document.createElement('div');
|
197
|
+
noResults.className = 'select-option no-results';
|
198
|
+
noResults.textContent = 'No results found';
|
199
|
+
this.dropdownTarget.appendChild(noResults);
|
200
|
+
return;
|
201
|
+
}
|
202
|
+
|
203
|
+
this.filteredOptions.forEach(option => {
|
204
|
+
const optionElement = document.createElement('div');
|
205
|
+
optionElement.className = 'select-option';
|
206
|
+
optionElement.dataset.selectTarget = 'option';
|
207
|
+
optionElement.dataset.value = option.value;
|
208
|
+
optionElement.textContent = option.text;
|
209
|
+
optionElement.addEventListener('click', () => {
|
210
|
+
this.selectOption(option.value, option.text);
|
211
|
+
});
|
212
|
+
this.dropdownTarget.appendChild(optionElement);
|
213
|
+
});
|
214
|
+
}
|
215
|
+
}
|
@@ -0,0 +1,122 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static targets = ["form", "variable"]
|
5
|
+
|
6
|
+
connect() {
|
7
|
+
// Override the global submitIfCompleted function from Blazer
|
8
|
+
window.submitIfCompleted = this.submitIfCompleted.bind(this)
|
9
|
+
|
10
|
+
// Set up event listeners for variable changes
|
11
|
+
this.setupVariableListeners()
|
12
|
+
}
|
13
|
+
|
14
|
+
setupVariableListeners() {
|
15
|
+
// Listen for changes on all variable inputs
|
16
|
+
this.variableTargets.forEach(input => {
|
17
|
+
input.addEventListener("change", (event) => {
|
18
|
+
this.handleVariableChange(event)
|
19
|
+
})
|
20
|
+
|
21
|
+
// Also listen for daterangepicker events if present
|
22
|
+
if (input.dataset.daterangepicker) {
|
23
|
+
const picker = $(input).data('daterangepicker')
|
24
|
+
if (picker) {
|
25
|
+
$(input).on('apply.daterangepicker', (ev, picker) => {
|
26
|
+
this.handleDateRangeChange(input, picker)
|
27
|
+
})
|
28
|
+
}
|
29
|
+
}
|
30
|
+
})
|
31
|
+
}
|
32
|
+
|
33
|
+
handleVariableChange(event) {
|
34
|
+
const input = event.target
|
35
|
+
console.log(`Variable ${input.name} changed to:`, input.value)
|
36
|
+
|
37
|
+
// Check if all required variables are filled and submit if so
|
38
|
+
setTimeout(() => {
|
39
|
+
this.submitIfCompleted(this.formTarget)
|
40
|
+
}, 100) // Small delay to ensure all values are updated
|
41
|
+
}
|
42
|
+
|
43
|
+
handleDateRangeChange(input, picker) {
|
44
|
+
console.log(`Date range updated for ${input.name}:`, input.value)
|
45
|
+
|
46
|
+
// Force update any related hidden fields
|
47
|
+
this.updateRelatedDateFields(input, picker)
|
48
|
+
|
49
|
+
// Submit the form after date range is updated
|
50
|
+
setTimeout(() => {
|
51
|
+
this.submitIfCompleted(this.formTarget)
|
52
|
+
}, 200) // Longer delay for date picker updates
|
53
|
+
}
|
54
|
+
|
55
|
+
updateRelatedDateFields(input, picker) {
|
56
|
+
// If this is a start_time/end_time combo, make sure both are updated
|
57
|
+
if (input.name === "start_time" || input.name === "end_time") {
|
58
|
+
const startTimeInput = this.formTarget.querySelector('input[name="start_time"]')
|
59
|
+
const endTimeInput = this.formTarget.querySelector('input[name="end_time"]')
|
60
|
+
|
61
|
+
if (startTimeInput && endTimeInput && picker) {
|
62
|
+
// Update both fields based on the picker values
|
63
|
+
if (picker.startDate) {
|
64
|
+
startTimeInput.value = picker.startDate.utc().format()
|
65
|
+
}
|
66
|
+
if (picker.endDate) {
|
67
|
+
endTimeInput.value = picker.endDate.endOf("day").utc().format()
|
68
|
+
}
|
69
|
+
}
|
70
|
+
}
|
71
|
+
}
|
72
|
+
|
73
|
+
submitIfCompleted(form) {
|
74
|
+
if (!form) return
|
75
|
+
|
76
|
+
let completed = true
|
77
|
+
const requiredInputs = form.querySelectorAll('input[name], select[name]')
|
78
|
+
|
79
|
+
// Check each required input
|
80
|
+
requiredInputs.forEach(input => {
|
81
|
+
const value = input.value
|
82
|
+
|
83
|
+
// More robust empty check
|
84
|
+
if (this.isEmpty(value)) {
|
85
|
+
completed = false
|
86
|
+
console.log(`Variable ${input.name} is empty:`, value)
|
87
|
+
} else {
|
88
|
+
console.log(`Variable ${input.name} has value:`, value)
|
89
|
+
}
|
90
|
+
})
|
91
|
+
|
92
|
+
console.log(`Form completion check: ${completed ? 'Complete' : 'Incomplete'}`)
|
93
|
+
|
94
|
+
if (completed) {
|
95
|
+
console.log('Submitting form with all variables filled')
|
96
|
+
form.submit()
|
97
|
+
}
|
98
|
+
}
|
99
|
+
|
100
|
+
isEmpty(value) {
|
101
|
+
// More comprehensive empty check
|
102
|
+
return value === null ||
|
103
|
+
value === undefined ||
|
104
|
+
value === "" ||
|
105
|
+
(typeof value === "string" && value.trim() === "")
|
106
|
+
}
|
107
|
+
|
108
|
+
// Manual trigger for testing
|
109
|
+
triggerSubmit() {
|
110
|
+
this.submitIfCompleted(this.formTarget)
|
111
|
+
}
|
112
|
+
|
113
|
+
// Debug method to check current variable states
|
114
|
+
debugVariables() {
|
115
|
+
console.log("=== Current Variable States ===")
|
116
|
+
const inputs = this.formTarget.querySelectorAll('input[name], select[name]')
|
117
|
+
inputs.forEach(input => {
|
118
|
+
console.log(`${input.name}: "${input.value}" (${typeof input.value})`)
|
119
|
+
})
|
120
|
+
console.log("===============================")
|
121
|
+
}
|
122
|
+
}
|
@@ -0,0 +1,21 @@
|
|
1
|
+
// Import controllers
|
2
|
+
import SearchController from "sage/controllers/search_controller"
|
3
|
+
import ClipboardController from "sage/controllers/clipboard_controller"
|
4
|
+
import SelectController from "sage/controllers/select_controller"
|
5
|
+
import DashboardController from "sage/controllers/dashboard_controller"
|
6
|
+
import ReverseInfiniteScrollController from "sage/controllers/reverse_infinite_scroll_controller"
|
7
|
+
import VariablesController from "sage/controllers/variables_controller"
|
8
|
+
|
9
|
+
// Export all Sage controllers for manual registration
|
10
|
+
export { SearchController, ClipboardController, SelectController, DashboardController, ReverseInfiniteScrollController, VariablesController }
|
11
|
+
|
12
|
+
// Register all Sage controllers with the provided Stimulus application
|
13
|
+
export function registerControllers(application) {
|
14
|
+
application.register("sage--search", SearchController)
|
15
|
+
application.register("sage--clipboard", ClipboardController)
|
16
|
+
application.register("sage--select", SelectController)
|
17
|
+
application.register("sage--dashboard", DashboardController)
|
18
|
+
application.register("sage--reverse-infinite-scroll", ReverseInfiniteScrollController)
|
19
|
+
application.register("sage--variables", VariablesController)
|
20
|
+
}
|
21
|
+
|
@@ -20,7 +20,6 @@ module Sage
|
|
20
20
|
end
|
21
21
|
|
22
22
|
def add_routes
|
23
|
-
# Remove existing Blazer route if present
|
24
23
|
routes_file = "config/routes.rb"
|
25
24
|
if File.exist?(routes_file)
|
26
25
|
routes_content = File.read(routes_file)
|
@@ -29,15 +28,19 @@ module Sage
|
|
29
28
|
blazer_route_pattern = /^\s*mount\s+Blazer::Engine\s*,\s*at:\s*['"]blazer['"]\s*$/
|
30
29
|
|
31
30
|
if routes_content.match?(blazer_route_pattern)
|
32
|
-
#
|
33
|
-
gsub_file routes_file, blazer_route_pattern, ""
|
34
|
-
say "
|
31
|
+
# Replace the Blazer route with Sage route
|
32
|
+
gsub_file routes_file, blazer_route_pattern, ' mount Sage::Engine => "/sage"'
|
33
|
+
say "Replaced Blazer route with Sage route at /sage", :green
|
34
|
+
else
|
35
|
+
# No existing Blazer route, add Sage route
|
36
|
+
route 'mount Sage::Engine => "/sage"'
|
37
|
+
say "Mounted Sage at /sage", :green
|
35
38
|
end
|
39
|
+
else
|
40
|
+
# No routes file, add Sage route
|
41
|
+
route 'mount Sage::Engine => "/sage"'
|
42
|
+
say "Mounted Sage at /sage", :green
|
36
43
|
end
|
37
|
-
|
38
|
-
# Mount Sage (which includes Blazer functionality)
|
39
|
-
route 'mount Sage::Engine => "/sage"'
|
40
|
-
say "Mounted Sage at /sage", :green
|
41
44
|
end
|
42
45
|
|
43
46
|
def create_initializer
|
@@ -72,31 +75,6 @@ module Sage
|
|
72
75
|
say "Created migration for sage_messages table", :green
|
73
76
|
end
|
74
77
|
|
75
|
-
def add_javascript_integration
|
76
|
-
say "Configuring JavaScript integration...", :green
|
77
|
-
|
78
|
-
# Update controllers/index.js to register Sage controllers
|
79
|
-
controllers_index_path = "app/javascript/controllers/index.js"
|
80
|
-
if File.exist?(controllers_index_path)
|
81
|
-
controllers_content = File.read(controllers_index_path)
|
82
|
-
unless controllers_content.include?("sage")
|
83
|
-
append_to_file controllers_index_path do
|
84
|
-
<<~JS
|
85
|
-
|
86
|
-
// Import and register Sage controllers
|
87
|
-
import { registerControllers } from "sage"
|
88
|
-
registerControllers(application)
|
89
|
-
JS
|
90
|
-
end
|
91
|
-
say "Updated controllers/index.js to register Sage controllers", :green
|
92
|
-
else
|
93
|
-
say "Sage controllers already registered in controllers/index.js", :yellow
|
94
|
-
end
|
95
|
-
else
|
96
|
-
say "Could not find app/javascript/controllers/index.js - you'll need to manually import Sage controllers", :yellow
|
97
|
-
end
|
98
|
-
end
|
99
|
-
|
100
78
|
def add_stylesheets
|
101
79
|
# Stylesheets are served directly from the engine via the asset pipeline
|
102
80
|
# No need to copy or require them - they're automatically available
|
data/lib/sage/engine.rb
CHANGED
@@ -141,8 +141,8 @@ module Sage
|
|
141
141
|
end
|
142
142
|
|
143
143
|
initializer "sage.assets" do |app|
|
144
|
-
# Add JavaScript
|
145
|
-
if
|
144
|
+
# Add JavaScript paths based on asset pipeline
|
145
|
+
if app.config.respond_to?(:assets)
|
146
146
|
app.config.assets.paths << Engine.root.join("app/javascript")
|
147
147
|
app.config.assets.paths << Engine.root.join("app/javascript/sage")
|
148
148
|
end
|
data/lib/sage/version.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: sage-rails
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.8
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nathan Jones
|
@@ -148,7 +148,14 @@ files:
|
|
148
148
|
- app/assets/images/sage/chevron-down-zinc-500.svg
|
149
149
|
- app/assets/images/sage/chevron-right.svg
|
150
150
|
- app/assets/images/sage/loading.svg
|
151
|
+
- app/assets/javascripts/sage.js
|
151
152
|
- app/assets/javascripts/sage/application.js
|
153
|
+
- app/assets/javascripts/sage/controllers/clipboard_controller.js
|
154
|
+
- app/assets/javascripts/sage/controllers/dashboard_controller.js
|
155
|
+
- app/assets/javascripts/sage/controllers/reverse_infinite_scroll_controller.js
|
156
|
+
- app/assets/javascripts/sage/controllers/search_controller.js
|
157
|
+
- app/assets/javascripts/sage/controllers/select_controller.js
|
158
|
+
- app/assets/javascripts/sage/controllers/variables_controller.js
|
152
159
|
- app/assets/stylesheets/sage/application.css
|
153
160
|
- app/controllers/sage/actions_controller.rb
|
154
161
|
- app/controllers/sage/application_controller.rb
|