collavre_slack 0.2.1 → 0.2.3
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/app/assets/stylesheets/collavre_slack/slack_integration.css +44 -0
- data/app/controllers/collavre_slack/creatives/slack_integrations_controller.rb +12 -4
- data/app/javascript/__tests__/slack_channel_list.test.js +166 -0
- data/app/javascript/collavre_slack.js +94 -11
- data/app/javascript/slack_channel_list.js +47 -0
- data/app/services/collavre_slack/slack_client.rb +20 -2
- data/app/views/collavre_slack/integrations/_modal.html.erb +3 -0
- data/config/locales/en.yml +3 -0
- data/config/locales/ko.yml +3 -0
- data/config/routes.rb +5 -1
- data/lib/collavre_slack/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 13a4005b2d4412089caa83e43e2be4868ce50aa024a77904e7644320d30a5cbb
|
|
4
|
+
data.tar.gz: c5a8501c5e9149b4648a10dc5eac2ede04451e0474cc0d59119f123b2d6763dd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 82d93fd5f769a694bb3afc27246b75f161b8f5d50afd9a09e1f1939eb26f5e774c193e9b6a2ce4d04c0ac5faf088c13abb6d693afdd0080463bd4b5ccb1fa463
|
|
7
|
+
data.tar.gz: 9de647749fb26de533513da779f9726185781071129c28080568402b8f30fb3037694175c7bf91f007f25acc005f07f44fc1186f49d01b15f719409633fe1a2a
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/* Slack channel search */
|
|
2
|
+
.slack-channel-search {
|
|
3
|
+
width: 100%;
|
|
4
|
+
padding: 0.5em 0.75em;
|
|
5
|
+
margin-bottom: 0.5em;
|
|
6
|
+
border: 1px solid var(--color-border);
|
|
7
|
+
border-radius: 4px;
|
|
8
|
+
font-size: 0.95em;
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
}
|
|
11
|
+
|
|
1
12
|
/* Slack channel selection */
|
|
2
13
|
.slack-channel-item {
|
|
3
14
|
padding: 0.5em 1em;
|
|
@@ -13,3 +24,36 @@
|
|
|
13
24
|
background: var(--color-drag-over);
|
|
14
25
|
border-left: 3px solid var(--color-secondary-active);
|
|
15
26
|
}
|
|
27
|
+
|
|
28
|
+
/* Slack badge in comments popup header */
|
|
29
|
+
.slack-channel-badge {
|
|
30
|
+
display: none;
|
|
31
|
+
font-size: 0.7em;
|
|
32
|
+
font-weight: 600;
|
|
33
|
+
background: #4A154B;
|
|
34
|
+
color: white;
|
|
35
|
+
padding: 0.15em 0.45em;
|
|
36
|
+
border-radius: 4px;
|
|
37
|
+
margin-left: 0.5em;
|
|
38
|
+
cursor: pointer;
|
|
39
|
+
white-space: nowrap;
|
|
40
|
+
vertical-align: middle;
|
|
41
|
+
line-height: 1.4;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.slack-channel-badge:hover {
|
|
45
|
+
background: #611f64;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.slack-channel-tooltip {
|
|
49
|
+
display: none;
|
|
50
|
+
font-size: 0.75em;
|
|
51
|
+
color: var(--color-text-secondary);
|
|
52
|
+
margin-left: 0.4em;
|
|
53
|
+
white-space: nowrap;
|
|
54
|
+
vertical-align: middle;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.slack-channel-tooltip.visible {
|
|
58
|
+
display: inline;
|
|
59
|
+
}
|
|
@@ -60,6 +60,17 @@ module CollavreSlack
|
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# Lightweight endpoint for badge display — returns only existing links, no Slack API calls
|
|
64
|
+
def badge
|
|
65
|
+
target_creative = @creative.effective_origin
|
|
66
|
+
links = SlackChannelLink.where(creative: target_creative)
|
|
67
|
+
render json: {
|
|
68
|
+
links: links.map { |link|
|
|
69
|
+
{ channel_name: link.channel_name }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
63
74
|
def destroy
|
|
64
75
|
link = SlackChannelLink.find(params[:id])
|
|
65
76
|
# Check against origin creative
|
|
@@ -86,10 +97,7 @@ module CollavreSlack
|
|
|
86
97
|
|
|
87
98
|
def fetch_channels(slack_account)
|
|
88
99
|
client = SlackClient.new(access_token: slack_account.access_token)
|
|
89
|
-
|
|
90
|
-
return [] unless response[:ok]
|
|
91
|
-
|
|
92
|
-
(response[:channels] || []).map do |channel|
|
|
100
|
+
client.list_all_channels.map do |channel|
|
|
93
101
|
{ id: channel[:id], name: channel[:name] }
|
|
94
102
|
end
|
|
95
103
|
rescue StandardError => e
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { filterChannels, reconcileSelection, buildChannelViewModels } from '../slack_channel_list.js';
|
|
5
|
+
|
|
6
|
+
const channels = [
|
|
7
|
+
{ id: 'C01', name: 'general' },
|
|
8
|
+
{ id: 'C02', name: 'random' },
|
|
9
|
+
{ id: 'C03', name: 'engineering' },
|
|
10
|
+
{ id: 'C04', name: 'design' },
|
|
11
|
+
{ id: 'C05', name: 'general-kr' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe('filterChannels', () => {
|
|
15
|
+
test('returns all channels when query is empty', () => {
|
|
16
|
+
expect(filterChannels(channels, '')).toEqual(channels);
|
|
17
|
+
expect(filterChannels(channels, null)).toEqual(channels);
|
|
18
|
+
expect(filterChannels(channels, undefined)).toEqual(channels);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('filters by substring match (case-insensitive)', () => {
|
|
22
|
+
const result = filterChannels(channels, 'gen');
|
|
23
|
+
expect(result).toEqual([
|
|
24
|
+
{ id: 'C01', name: 'general' },
|
|
25
|
+
{ id: 'C05', name: 'general-kr' },
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('is case-insensitive', () => {
|
|
30
|
+
expect(filterChannels(channels, 'DESIGN')).toEqual([
|
|
31
|
+
{ id: 'C04', name: 'design' },
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('returns empty array when nothing matches', () => {
|
|
36
|
+
expect(filterChannels(channels, 'zzz')).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('returns empty array when channels list is empty', () => {
|
|
40
|
+
expect(filterChannels([], 'gen')).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('reconcileSelection', () => {
|
|
45
|
+
test('returns null when no channel is selected', () => {
|
|
46
|
+
expect(reconcileSelection(null, channels)).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('keeps selection when channel is in filtered list', () => {
|
|
50
|
+
const selected = { id: 'C01', name: 'general' };
|
|
51
|
+
expect(reconcileSelection(selected, channels)).toBe(selected);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('clears selection when channel is filtered out', () => {
|
|
55
|
+
const selected = { id: 'C01', name: 'general' };
|
|
56
|
+
const filtered = [{ id: 'C04', name: 'design' }];
|
|
57
|
+
expect(reconcileSelection(selected, filtered)).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('clears selection when filtered list is empty', () => {
|
|
61
|
+
const selected = { id: 'C01', name: 'general' };
|
|
62
|
+
expect(reconcileSelection(selected, [])).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('buildChannelViewModels', () => {
|
|
67
|
+
test('marks linked channels', () => {
|
|
68
|
+
const links = [{ channel_id: 'C02' }];
|
|
69
|
+
const result = buildChannelViewModels(channels, links, null);
|
|
70
|
+
|
|
71
|
+
const linkedItem = result.find(vm => vm.channel.id === 'C02');
|
|
72
|
+
expect(linkedItem.isLinked).toBe(true);
|
|
73
|
+
|
|
74
|
+
const unlinkedItem = result.find(vm => vm.channel.id === 'C01');
|
|
75
|
+
expect(unlinkedItem.isLinked).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('marks selected channel', () => {
|
|
79
|
+
const selected = { id: 'C03', name: 'engineering' };
|
|
80
|
+
const result = buildChannelViewModels(channels, [], selected);
|
|
81
|
+
|
|
82
|
+
const selectedItem = result.find(vm => vm.channel.id === 'C03');
|
|
83
|
+
expect(selectedItem.isSelected).toBe(true);
|
|
84
|
+
|
|
85
|
+
const otherItem = result.find(vm => vm.channel.id === 'C01');
|
|
86
|
+
expect(otherItem.isSelected).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('no channel is selected when selection is null', () => {
|
|
90
|
+
const result = buildChannelViewModels(channels, [], null);
|
|
91
|
+
expect(result.every(vm => !vm.isSelected)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('handles empty inputs', () => {
|
|
95
|
+
expect(buildChannelViewModels([], [], null)).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('channel can be both linked and selected', () => {
|
|
99
|
+
const selected = { id: 'C02', name: 'random' };
|
|
100
|
+
const links = [{ channel_id: 'C02' }];
|
|
101
|
+
const result = buildChannelViewModels(channels, links, selected);
|
|
102
|
+
|
|
103
|
+
const item = result.find(vm => vm.channel.id === 'C02');
|
|
104
|
+
expect(item.isLinked).toBe(true);
|
|
105
|
+
expect(item.isSelected).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('search → selection regression', () => {
|
|
110
|
+
test('selecting a channel then filtering it out clears selection', () => {
|
|
111
|
+
// User selects #general
|
|
112
|
+
const selected = { id: 'C01', name: 'general' };
|
|
113
|
+
|
|
114
|
+
// Then types "design" in search
|
|
115
|
+
const filtered = filterChannels(channels, 'design');
|
|
116
|
+
expect(filtered).toEqual([{ id: 'C04', name: 'design' }]);
|
|
117
|
+
|
|
118
|
+
// Selection should be cleared because #general is not in results
|
|
119
|
+
const reconciled = reconcileSelection(selected, filtered);
|
|
120
|
+
expect(reconciled).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('selecting a channel then filtering to include it keeps selection', () => {
|
|
124
|
+
const selected = { id: 'C01', name: 'general' };
|
|
125
|
+
const filtered = filterChannels(channels, 'gen');
|
|
126
|
+
|
|
127
|
+
// #general is still in the results
|
|
128
|
+
const reconciled = reconcileSelection(selected, filtered);
|
|
129
|
+
expect(reconciled).toBe(selected);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('clearing the search restores selection if channel exists', () => {
|
|
133
|
+
const selected = { id: 'C01', name: 'general' };
|
|
134
|
+
|
|
135
|
+
// Filter out, selection cleared
|
|
136
|
+
const filtered1 = filterChannels(channels, 'design');
|
|
137
|
+
const reconciled1 = reconcileSelection(selected, filtered1);
|
|
138
|
+
expect(reconciled1).toBeNull();
|
|
139
|
+
|
|
140
|
+
// Clear search — but reconcileSelection won't magically restore,
|
|
141
|
+
// it returns null because reconciled1 was already null
|
|
142
|
+
const filtered2 = filterChannels(channels, '');
|
|
143
|
+
const reconciled2 = reconcileSelection(reconciled1, filtered2);
|
|
144
|
+
expect(reconciled2).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('full flow: filter → select → widen filter keeps new selection', () => {
|
|
148
|
+
// User types "des", sees only #design
|
|
149
|
+
const filtered1 = filterChannels(channels, 'des');
|
|
150
|
+
expect(filtered1).toEqual([{ id: 'C04', name: 'design' }]);
|
|
151
|
+
|
|
152
|
+
// User selects #design
|
|
153
|
+
const selected = { id: 'C04', name: 'design' };
|
|
154
|
+
|
|
155
|
+
// User clears search — #design is still in full list
|
|
156
|
+
const filtered2 = filterChannels(channels, '');
|
|
157
|
+
const reconciled = reconcileSelection(selected, filtered2);
|
|
158
|
+
expect(reconciled).toBe(selected);
|
|
159
|
+
|
|
160
|
+
// View models reflect selection correctly
|
|
161
|
+
const vms = buildChannelViewModels(filtered2, [], reconciled);
|
|
162
|
+
const designVm = vms.find(vm => vm.channel.id === 'C04');
|
|
163
|
+
expect(designVm.isSelected).toBe(true);
|
|
164
|
+
expect(vms.filter(vm => vm.isSelected)).toHaveLength(1);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { filterChannels, reconcileSelection, buildChannelViewModels } from './slack_channel_list.js';
|
|
2
|
+
|
|
1
3
|
let slackIntegrationInitialized = false;
|
|
2
4
|
|
|
3
5
|
if (!slackIntegrationInitialized) {
|
|
@@ -23,6 +25,7 @@ if (!slackIntegrationInitialized) {
|
|
|
23
25
|
const addChannelBtn = document.getElementById('slack-add-channel-btn');
|
|
24
26
|
const connectMessage = document.getElementById('slack-connect-message');
|
|
25
27
|
const channelListEl = document.getElementById('slack-channel-list');
|
|
28
|
+
const channelSearchEl = document.getElementById('slack-channel-search');
|
|
26
29
|
const channelSummaryEl = document.getElementById('slack-channel-summary');
|
|
27
30
|
|
|
28
31
|
let creativeId = null;
|
|
@@ -250,31 +253,46 @@ if (!slackIntegrationInitialized) {
|
|
|
250
253
|
});
|
|
251
254
|
}
|
|
252
255
|
|
|
253
|
-
function renderChannelList() {
|
|
256
|
+
function renderChannelList(filter) {
|
|
254
257
|
if (!channelListEl) return;
|
|
255
258
|
|
|
256
259
|
channelListEl.innerHTML = '';
|
|
257
260
|
|
|
258
261
|
if (availableChannels.length === 0) {
|
|
259
|
-
channelListEl.innerHTML =
|
|
262
|
+
channelListEl.innerHTML = `<p style="padding:0.5em;color:var(--color-text-secondary);">${modal.dataset.noChannelsAvailable || 'No channels available'}</p>`;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const filtered = filterChannels(availableChannels, filter);
|
|
267
|
+
|
|
268
|
+
// Clear selection if the selected channel is not in the filtered results
|
|
269
|
+
selectedChannel = reconcileSelection(selectedChannel, filtered);
|
|
270
|
+
nextBtn.disabled = !selectedChannel;
|
|
271
|
+
|
|
272
|
+
if (filtered.length === 0) {
|
|
273
|
+
channelListEl.innerHTML = `<p style="padding:0.5em;color:var(--color-text-secondary);">${modal.dataset.noMatchingChannels || 'No matching channels'}</p>`;
|
|
260
274
|
return;
|
|
261
275
|
}
|
|
262
276
|
|
|
263
|
-
|
|
277
|
+
const viewModels = buildChannelViewModels(filtered, existingLinks, selectedChannel);
|
|
278
|
+
|
|
279
|
+
viewModels.forEach(function ({ channel, isLinked, isSelected }) {
|
|
264
280
|
const div = document.createElement('div');
|
|
265
281
|
div.className = 'slack-channel-item';
|
|
266
282
|
|
|
267
|
-
const isLinked = existingLinks.some(link => link.channel_id === channel.id);
|
|
268
|
-
|
|
269
283
|
const linkedLabel = modal.dataset.linkedLabel || '(linked)';
|
|
270
284
|
div.innerHTML = `
|
|
271
285
|
<strong>#${channel.name}</strong>
|
|
272
286
|
${isLinked ? `<span style="color:green;margin-left:0.5em;">${linkedLabel}</span>` : ''}
|
|
273
287
|
`;
|
|
274
288
|
|
|
289
|
+
if (isSelected) {
|
|
290
|
+
div.classList.add('active');
|
|
291
|
+
}
|
|
292
|
+
|
|
275
293
|
if (!isLinked) {
|
|
276
294
|
div.addEventListener('click', function () {
|
|
277
|
-
|
|
295
|
+
channelListEl.querySelectorAll('.slack-channel-item').forEach(el => {
|
|
278
296
|
el.classList.remove('active');
|
|
279
297
|
});
|
|
280
298
|
div.classList.add('active');
|
|
@@ -396,6 +414,7 @@ if (!slackIntegrationInitialized) {
|
|
|
396
414
|
clearError();
|
|
397
415
|
if (currentStep === 'connect') {
|
|
398
416
|
currentStep = 'channels';
|
|
417
|
+
if (channelSearchEl) channelSearchEl.value = '';
|
|
399
418
|
renderChannelList();
|
|
400
419
|
} else if (currentStep === 'channels') {
|
|
401
420
|
if (!selectedChannel) {
|
|
@@ -410,10 +429,17 @@ if (!slackIntegrationInitialized) {
|
|
|
410
429
|
|
|
411
430
|
finishBtn.addEventListener('click', performLink);
|
|
412
431
|
|
|
432
|
+
if (channelSearchEl) {
|
|
433
|
+
channelSearchEl.addEventListener('input', function () {
|
|
434
|
+
renderChannelList(this.value);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
413
438
|
if (addChannelBtn) {
|
|
414
439
|
addChannelBtn.addEventListener('click', function () {
|
|
415
440
|
currentStep = 'channels';
|
|
416
441
|
selectedChannel = null;
|
|
442
|
+
if (channelSearchEl) channelSearchEl.value = '';
|
|
417
443
|
renderChannelList();
|
|
418
444
|
updateStep();
|
|
419
445
|
});
|
|
@@ -437,7 +463,7 @@ if (!slackIntegrationInitialized) {
|
|
|
437
463
|
if (data.connected) {
|
|
438
464
|
availableChannels = data.channels || [];
|
|
439
465
|
existingLinks = data.links || [];
|
|
440
|
-
renderChannelList();
|
|
466
|
+
renderChannelList(channelSearchEl ? channelSearchEl.value : '');
|
|
441
467
|
}
|
|
442
468
|
})
|
|
443
469
|
.catch(error => {
|
|
@@ -461,35 +487,79 @@ if (!slackIntegrationInitialized) {
|
|
|
461
487
|
});
|
|
462
488
|
|
|
463
489
|
// Slack badge for comments popup
|
|
490
|
+
// Store references for cleanup to avoid handler accumulation across popup opens
|
|
491
|
+
let slackBadgeClickHandler = null;
|
|
492
|
+
let slackDocumentClickHandler = null;
|
|
493
|
+
let slackBadgeRequestId = 0; // Guard against stale async responses
|
|
494
|
+
|
|
464
495
|
document.addEventListener('comments-popup:opened', async function (event) {
|
|
465
496
|
const { creativeId, badgeContainer } = event.detail;
|
|
466
497
|
if (!badgeContainer || !creativeId) return;
|
|
467
498
|
|
|
499
|
+
// Increment request id to invalidate any in-flight fetch
|
|
500
|
+
const currentRequestId = ++slackBadgeRequestId;
|
|
501
|
+
|
|
468
502
|
// Create or find slack badge element
|
|
469
503
|
let badge = badgeContainer.querySelector('[data-slack-badge]');
|
|
470
504
|
if (!badge) {
|
|
471
505
|
badge = document.createElement('span');
|
|
472
506
|
badge.setAttribute('data-slack-badge', '');
|
|
473
507
|
badge.className = 'slack-channel-badge';
|
|
474
|
-
badge.style.cssText = 'display:none;font-size:0.75em;background:#4A154B;color:white;padding:0.15em 0.5em;border-radius:4px;margin-left:0.5em;';
|
|
475
508
|
badgeContainer.appendChild(badge);
|
|
476
509
|
}
|
|
477
510
|
|
|
511
|
+
// Clean up previous handlers before re-registering
|
|
512
|
+
if (slackBadgeClickHandler) {
|
|
513
|
+
badge.removeEventListener('click', slackBadgeClickHandler);
|
|
514
|
+
slackBadgeClickHandler = null;
|
|
515
|
+
}
|
|
516
|
+
if (slackDocumentClickHandler) {
|
|
517
|
+
document.removeEventListener('click', slackDocumentClickHandler);
|
|
518
|
+
slackDocumentClickHandler = null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Remove any existing tooltip
|
|
522
|
+
const existingTooltip = badgeContainer.querySelector('[data-slack-tooltip]');
|
|
523
|
+
if (existingTooltip) existingTooltip.remove();
|
|
524
|
+
|
|
478
525
|
// Hide badge initially
|
|
479
526
|
badge.style.display = 'none';
|
|
480
527
|
badge.textContent = '';
|
|
481
528
|
|
|
482
529
|
try {
|
|
483
|
-
const response = await fetch(`/slack/creatives/${creativeId}/slack_integrations`, {
|
|
530
|
+
const response = await fetch(`/slack/creatives/${creativeId}/slack_integrations/badge`, {
|
|
484
531
|
headers: { Accept: 'application/json' }
|
|
485
532
|
});
|
|
533
|
+
|
|
534
|
+
// Discard response if a newer popup open has occurred
|
|
535
|
+
if (currentRequestId !== slackBadgeRequestId) return;
|
|
536
|
+
|
|
486
537
|
if (!response.ok) return;
|
|
487
538
|
|
|
488
539
|
const data = await response.json();
|
|
489
540
|
if (data.links && data.links.length > 0) {
|
|
490
|
-
|
|
491
|
-
badge.textContent = `Slack: ${channelNames}`;
|
|
541
|
+
badge.textContent = 'Slack';
|
|
492
542
|
badge.style.display = 'inline-block';
|
|
543
|
+
badge.title = data.links.map(link => `#${link.channel_name}`).join(', ');
|
|
544
|
+
|
|
545
|
+
// Create tooltip element for click display
|
|
546
|
+
const tooltip = document.createElement('span');
|
|
547
|
+
tooltip.setAttribute('data-slack-tooltip', '');
|
|
548
|
+
tooltip.className = 'slack-channel-tooltip';
|
|
549
|
+
tooltip.textContent = data.links.map(link => `#${link.channel_name}`).join(', ');
|
|
550
|
+
badgeContainer.appendChild(tooltip);
|
|
551
|
+
|
|
552
|
+
slackBadgeClickHandler = function (e) {
|
|
553
|
+
e.stopPropagation();
|
|
554
|
+
tooltip.classList.toggle('visible');
|
|
555
|
+
};
|
|
556
|
+
badge.addEventListener('click', slackBadgeClickHandler);
|
|
557
|
+
|
|
558
|
+
// Close tooltip when clicking elsewhere
|
|
559
|
+
slackDocumentClickHandler = function () {
|
|
560
|
+
tooltip.classList.remove('visible');
|
|
561
|
+
};
|
|
562
|
+
document.addEventListener('click', slackDocumentClickHandler);
|
|
493
563
|
}
|
|
494
564
|
} catch (error) {
|
|
495
565
|
console.warn('Failed to load Slack badge:', error);
|
|
@@ -500,10 +570,23 @@ if (!slackIntegrationInitialized) {
|
|
|
500
570
|
const { badgeContainer } = event.detail;
|
|
501
571
|
if (!badgeContainer) return;
|
|
502
572
|
|
|
573
|
+
// Invalidate any in-flight badge fetch so late responses are discarded
|
|
574
|
+
slackBadgeRequestId++;
|
|
575
|
+
|
|
503
576
|
const badge = badgeContainer.querySelector('[data-slack-badge]');
|
|
504
577
|
if (badge) {
|
|
505
578
|
badge.style.display = 'none';
|
|
506
579
|
badge.textContent = '';
|
|
580
|
+
if (slackBadgeClickHandler) {
|
|
581
|
+
badge.removeEventListener('click', slackBadgeClickHandler);
|
|
582
|
+
slackBadgeClickHandler = null;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (slackDocumentClickHandler) {
|
|
586
|
+
document.removeEventListener('click', slackDocumentClickHandler);
|
|
587
|
+
slackDocumentClickHandler = null;
|
|
507
588
|
}
|
|
589
|
+
const tooltip = badgeContainer.querySelector('[data-slack-tooltip]');
|
|
590
|
+
if (tooltip) tooltip.remove();
|
|
508
591
|
});
|
|
509
592
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-logic helpers for the Slack channel list.
|
|
3
|
+
* Extracted so they can be unit-tested without a full DOM wizard.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Filter channels by a search query (case-insensitive substring match on name).
|
|
8
|
+
* @param {Array<{id:string, name:string}>} channels
|
|
9
|
+
* @param {string} query
|
|
10
|
+
* @returns {Array<{id:string, name:string}>}
|
|
11
|
+
*/
|
|
12
|
+
export function filterChannels(channels, query) {
|
|
13
|
+
const q = (query || '').toLowerCase();
|
|
14
|
+
if (!q) return channels;
|
|
15
|
+
return channels.filter(ch => ch.name.toLowerCase().includes(q));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decide whether selectedChannel should be cleared after a filter change.
|
|
20
|
+
* Returns the selectedChannel as-is if it still exists in `filtered`,
|
|
21
|
+
* or null if it has been filtered out.
|
|
22
|
+
*
|
|
23
|
+
* @param {{id:string, name:string}|null} selectedChannel
|
|
24
|
+
* @param {Array<{id:string, name:string}>} filtered
|
|
25
|
+
* @returns {{id:string, name:string}|null}
|
|
26
|
+
*/
|
|
27
|
+
export function reconcileSelection(selectedChannel, filtered) {
|
|
28
|
+
if (!selectedChannel) return null;
|
|
29
|
+
return filtered.some(ch => ch.id === selectedChannel.id) ? selectedChannel : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the view-model array consumed by the DOM renderer.
|
|
34
|
+
*
|
|
35
|
+
* @param {Array<{id:string, name:string}>} channels – filtered channel list
|
|
36
|
+
* @param {Array<{channel_id:string}>} links – already-linked channels
|
|
37
|
+
* @param {{id:string}|null} selected – currently selected channel
|
|
38
|
+
* @returns {Array<{channel, isLinked:boolean, isSelected:boolean}>}
|
|
39
|
+
*/
|
|
40
|
+
export function buildChannelViewModels(channels, links, selected) {
|
|
41
|
+
const linkedIds = new Set(links.map(l => l.channel_id));
|
|
42
|
+
return channels.map(channel => ({
|
|
43
|
+
channel,
|
|
44
|
+
isLinked: linkedIds.has(channel.id),
|
|
45
|
+
isSelected: selected !== null && selected.id === channel.id,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
@@ -28,8 +28,26 @@ module CollavreSlack
|
|
|
28
28
|
parse_response(response)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def list_channels
|
|
32
|
-
|
|
31
|
+
def list_channels(cursor: nil)
|
|
32
|
+
params = { limit: 200, types: "public_channel,private_channel" }
|
|
33
|
+
params[:cursor] = cursor if cursor.present?
|
|
34
|
+
get("conversations.list", params)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def list_all_channels
|
|
38
|
+
channels = []
|
|
39
|
+
cursor = nil
|
|
40
|
+
|
|
41
|
+
loop do
|
|
42
|
+
response = list_channels(cursor: cursor)
|
|
43
|
+
break unless response[:ok]
|
|
44
|
+
|
|
45
|
+
channels.concat(Array(response[:channels]))
|
|
46
|
+
cursor = response.dig(:response_metadata, :next_cursor)
|
|
47
|
+
break if cursor.blank?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
channels
|
|
33
51
|
end
|
|
34
52
|
|
|
35
53
|
def list_messages(channel:, oldest: nil, cursor: nil)
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
data-refresh="<%= t('collavre_slack.modal.refresh') %>"
|
|
15
15
|
data-refresh-error="<%= t('collavre_slack.modal.refresh_error') %>"
|
|
16
16
|
data-linked-label="<%= t('collavre_slack.modal.linked_label') %>"
|
|
17
|
+
data-no-channels-available="<%= t('collavre_slack.modal.no_channels_available') %>"
|
|
18
|
+
data-no-matching-channels="<%= t('collavre_slack.modal.no_matching_channels') %>"
|
|
17
19
|
style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1000;align-items:center;justify-content:center;">
|
|
18
20
|
<div class="popup-box" style="min-width:360px;max-width:90vw;">
|
|
19
21
|
<button type="button" id="close-slack-modal" class="popup-close-btn">×</button>
|
|
@@ -41,6 +43,7 @@
|
|
|
41
43
|
|
|
42
44
|
<div class="slack-wizard-step" id="slack-step-channels" style="display:none;">
|
|
43
45
|
<p class="slack-modal-subtext"><%= t('collavre_slack.modal.choose_channel') %></p>
|
|
46
|
+
<input type="text" id="slack-channel-search" class="slack-channel-search" placeholder="<%= t('collavre_slack.modal.search_channels') %>" autocomplete="off">
|
|
44
47
|
<div id="slack-channel-list" class="slack-list slack-modal-list-box" style="max-height:240px;overflow:auto;"></div>
|
|
45
48
|
</div>
|
|
46
49
|
|
data/config/locales/en.yml
CHANGED
data/config/locales/ko.yml
CHANGED
data/config/routes.rb
CHANGED
|
@@ -3,7 +3,11 @@ CollavreSlack::Engine.routes.draw do
|
|
|
3
3
|
get "/auth/slack/callback", to: "slack_auth#callback"
|
|
4
4
|
|
|
5
5
|
resources :creatives, only: [] do
|
|
6
|
-
resources :slack_integrations, module: :creatives, only: [ :index, :create, :destroy ]
|
|
6
|
+
resources :slack_integrations, module: :creatives, only: [ :index, :create, :destroy ] do
|
|
7
|
+
collection do
|
|
8
|
+
get :badge
|
|
9
|
+
end
|
|
10
|
+
end
|
|
7
11
|
resources :slack_messages, only: [ :create ]
|
|
8
12
|
end
|
|
9
13
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: collavre_slack
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Collavre
|
|
@@ -81,7 +81,9 @@ files:
|
|
|
81
81
|
- app/controllers/collavre_slack/slack_auth_controller.rb
|
|
82
82
|
- app/controllers/collavre_slack/slack_events_controller.rb
|
|
83
83
|
- app/controllers/collavre_slack/slack_messages_controller.rb
|
|
84
|
+
- app/javascript/__tests__/slack_channel_list.test.js
|
|
84
85
|
- app/javascript/collavre_slack.js
|
|
86
|
+
- app/javascript/slack_channel_list.js
|
|
85
87
|
- app/jobs/collavre_slack/application_job.rb
|
|
86
88
|
- app/jobs/collavre_slack/slack_channel_sync_job.rb
|
|
87
89
|
- app/jobs/collavre_slack/slack_inbound_message_delete_job.rb
|