collavre_slack 0.2.1 → 0.2.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa613acb914f114910acfe095639ddc59da1e61f354027db1cd787f21771936c
4
- data.tar.gz: 3920b7a19ff81f3e631056e68e94e6d635a3bf01bf27121d24b96867fa74b290
3
+ metadata.gz: 50c834145802449d01365be47375e1d4bbb4a05a2622abee20449d75b24c8c2c
4
+ data.tar.gz: d7b9d88bad8aac159618cd4f336937aea08e43362c2c2891304a0a895bfd2ece
5
5
  SHA512:
6
- metadata.gz: b7a05dd936fe10a0ad171794034c69718f1916a6eaa5f6a2f9b35e70c2b973fb6d2aad40d2ef536d6c22d4fedf5760d8ab2ea4c42554ef63189eda635ff1fe72
7
- data.tar.gz: 80ecdcf143752eced3b5fdce64d75fb788b4b541ad0ef68eb07d6a479a249ce33834147f3938ed524314c479799a1d2fe19d7227ba7a91e40e35618600284070
6
+ metadata.gz: b1c34b49e220e6b2cd44e1978f5b3b9fd088d2f18d85f6e8628817486ae7ecf110e2f09a81cf25fb6ea9f6aa4e3a76fcbba61970067a3a5fb866495a82d9050b
7
+ data.tar.gz: 60b924d455319f87ea3de4ab8a340fb7b93b20df413cb1e96d5e3c78bacb09d94888eda06f0ed08028072e3cae19265b3c88aa5403856147dd0bb7c2766b67cc
@@ -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;
@@ -86,10 +86,7 @@ module CollavreSlack
86
86
 
87
87
  def fetch_channels(slack_account)
88
88
  client = SlackClient.new(access_token: slack_account.access_token)
89
- response = client.list_channels
90
- return [] unless response[:ok]
91
-
92
- (response[:channels] || []).map do |channel|
89
+ client.list_all_channels.map do |channel|
93
90
  { id: channel[:id], name: channel[:name] }
94
91
  end
95
92
  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 = '<p style="padding:0.5em;color:var(--color-text-secondary);">No channels available</p>';
262
+ channelListEl.innerHTML = `<p style="padding:0.5em;color:var(--color-text-secondary);">${modal.dataset.noChannelsAvailable || 'No channels available'}</p>`;
260
263
  return;
261
264
  }
262
265
 
263
- availableChannels.forEach(function (channel) {
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>`;
274
+ return;
275
+ }
276
+
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
- document.querySelectorAll('.slack-channel-item').forEach(el => {
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 => {
@@ -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
- get("conversations.list", limit: 1000, types: "public_channel,private_channel")
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">&times;</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
 
@@ -55,3 +55,6 @@ en:
55
55
  refresh: "Refresh"
56
56
  refresh_error: "Failed to refresh channels"
57
57
  linked_label: "(Linked)"
58
+ search_channels: "Search channels..."
59
+ no_channels_available: "No channels available"
60
+ no_matching_channels: "No matching channels"
@@ -55,3 +55,6 @@ ko:
55
55
  refresh: "새로고침"
56
56
  refresh_error: "채널 목록을 새로고침하는데 실패했습니다"
57
57
  linked_label: "(연결됨)"
58
+ search_channels: "채널 검색..."
59
+ no_channels_available: "사용 가능한 채널이 없습니다"
60
+ no_matching_channels: "일치하는 채널이 없습니다"
@@ -1,3 +1,3 @@
1
1
  module CollavreSlack
2
- VERSION = "0.2.1"
2
+ VERSION = "0.2.2"
3
3
  end
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.1
4
+ version: 0.2.2
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