itube-specs 0.0.915 → 0.0.917

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.
@@ -0,0 +1 @@
1
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="6.5"></circle><circle cx="12" cy="9" r="2.5"></circle><path d="M12 15.5V19"></path><path d="M8 20h8"></path></svg>
@@ -42,7 +42,7 @@ $nav-border--active: $color-indigo-500-50;
42
42
  $nav-border--hover: transparent;
43
43
  $nav-border: $color-zinc-700-50;
44
44
  $nav-icon: $color-zinc-400;
45
- $nav-icon-accent: $color-red-400;
45
+ $nav-icon-accent: $color-emerald-400;
46
46
  $nav-icon--active: $color-zinc-400;
47
47
  $nav-container-bg: $color-black;
48
48
  $nav-down-icon: $color-zinc-500;
@@ -867,6 +867,17 @@ $playlist-card-placeholder-text: $color-zinc-400;
867
867
  $playlist-card-placeholder-bg: $color-zinc-700;
868
868
  $playlist-card-dropdown-icon: $color-zinc-500;
869
869
  $playlist-card-dropdown-icon--hover: $color-white;
870
+ $playlist-card-visibility-icon: $color-zinc-400;
871
+ $playlist-card-delete-text: $color-red-400;
872
+
873
+ // ─── My playlists empty state ───
874
+ $playlists-empty-border: $color-zinc-800;
875
+ $playlists-empty-title: $color-white;
876
+ $playlists-empty-text: $color-zinc-400;
877
+ $playlists-empty-chip-bg: $color-zinc-800;
878
+ $playlists-empty-chip-text: $color-zinc-300;
879
+ $playlists-empty-chip-icon: $color-zinc-400;
880
+ $playlists-empty-arrow: $color-zinc-600;
870
881
 
871
882
  // ─── Collections section ───
872
883
  $collections-badge-text: $color-indigo-300;
@@ -895,6 +895,17 @@ $playlist-card-placeholder-text: $color-gray-700;
895
895
  $playlist-card-placeholder-bg: $color-gray-300;
896
896
  $playlist-card-dropdown-icon: $color-gray-600;
897
897
  $playlist-card-dropdown-icon--hover: $color-gray-900;
898
+ $playlist-card-visibility-icon: $color-gray-500;
899
+ $playlist-card-delete-text: $color-red-400;
900
+
901
+ // ─── My playlists empty state ───
902
+ $playlists-empty-border: $color-gray-300;
903
+ $playlists-empty-title: $color-gray-900;
904
+ $playlists-empty-text: $color-gray-600;
905
+ $playlists-empty-chip-bg: $color-gray-100;
906
+ $playlists-empty-chip-text: $color-gray-700;
907
+ $playlists-empty-chip-icon: $color-gray-500;
908
+ $playlists-empty-arrow: $color-gray-400;
898
909
 
899
910
  // ─── Collections section ───
900
911
  $collections-badge-text: $color-indigo-600;
@@ -33,19 +33,18 @@
33
33
  size="16"
34
34
  />
35
35
  </UiLabel>
36
-
37
- <UiLabel
38
- v-if="card.playlistType === 'private'"
39
- :text="t('private')"
40
- class="card-playlist__private"
41
- theme="backdrop"
42
- >
43
- <UiIcon
44
- name="lock"
45
- size="16"
46
- />
47
- </UiLabel>
48
36
  </NuxtLink>
37
+
38
+ <!-- Кнопка живёт снаружи ссылки-обложки: button внутри a — невалидная разметка -->
39
+ <UiFollowBtn
40
+ v-if="showFollow"
41
+ :following="following"
42
+ :name="card.name"
43
+ class="card-playlist__follow"
44
+ size="s"
45
+ @toggle="toggle(card.id)"
46
+ />
47
+
49
48
  <div class="card-playlist__wrapper">
50
49
  <div class="card-playlist__wrapper-header">
51
50
  <p class="card-playlist__numbers">
@@ -75,7 +74,8 @@
75
74
  name="date"
76
75
  size="14"
77
76
  />
78
- {{ formatDate(card.created) }}
77
+ <span class="card-playlist__updated">{{ t('library.updated') }}</span>
78
+ <span>{{ formatDate(card.created) }}</span>
79
79
  </p>
80
80
  </div>
81
81
  <div class="card-playlist__content">
@@ -98,6 +98,17 @@
98
98
  :to="generateLink(`/playlists/${card.id}/${card.firstVideoId}`)"
99
99
  class="card-playlist__title"
100
100
  >
101
+ <!-- У своих плейлистов приватность видна у названия: значок вместо плашки на обложке -->
102
+ <span
103
+ v-if="own"
104
+ :title="visibilityTitle"
105
+ class="card-playlist__visibility"
106
+ >
107
+ <UiIcon
108
+ :name="PLAYLIST_TYPE_ICON[card.playlistType]"
109
+ size="14"
110
+ />
111
+ </span>
101
112
  <span class="card-playlist__name">{{ card.name }}</span>
102
113
  <span
103
114
  v-if="card.newCount"
@@ -124,7 +135,7 @@
124
135
  >
125
136
  <UiIcon
126
137
  name="dots-vertical"
127
- size="16"
138
+ size="18"
128
139
  />
129
140
  </button>
130
141
  </template>
@@ -135,39 +146,65 @@
135
146
  />
136
147
  </template>
137
148
  <template #items="className">
138
- <button
139
- :class="className"
140
- type="button"
141
- @click="toggleFavorite(card.id, card.name)"
142
- >
143
- <UiIcon
144
- name="heart-stroke"
145
- size="16"
146
- />
147
- {{ favoritesLabel }}
148
- </button>
149
- <button
150
- :class="className"
151
- type="button"
152
- @click="emit('share', card)"
153
- >
154
- <UiIcon
155
- name="share"
156
- size="16"
157
- />
158
- {{ t('share') }}
159
- </button>
160
- <button
161
- :class="className"
162
- type="button"
163
- @click="onReport"
164
- >
165
- <UiIcon
166
- name="flag"
167
- size="16"
168
- />
169
- {{ t('report') }}
170
- </button>
149
+ <template v-if="own">
150
+ <button
151
+ :class="className"
152
+ type="button"
153
+ @click="openPlaylistEdit(PlaylistStep.Edit, false, card)"
154
+ >
155
+ <UiIcon
156
+ name="edit"
157
+ size="16"
158
+ />
159
+ {{ t('playlist.edit') }}
160
+ </button>
161
+ <button
162
+ :class="[className, 'card-playlist__delete']"
163
+ type="button"
164
+ @click="openPlaylistEdit(PlaylistStep.Delete, false, card)"
165
+ >
166
+ <UiIcon
167
+ name="trash-bin"
168
+ size="16"
169
+ />
170
+ {{ t('playlist.delete') }}
171
+ </button>
172
+ </template>
173
+ <template v-else>
174
+ <button
175
+ :class="className"
176
+ type="button"
177
+ @click="toggleFollow(card.id, card.name)"
178
+ >
179
+ <UiIcon
180
+ :name="shownFollowing ? 'minus' : 'plus'"
181
+ size="16"
182
+ />
183
+ {{ followLabel }}
184
+ </button>
185
+ <button
186
+ :class="className"
187
+ type="button"
188
+ @click="emit('share', card)"
189
+ >
190
+ <UiIcon
191
+ name="share"
192
+ size="16"
193
+ />
194
+ {{ t('share') }}
195
+ </button>
196
+ <button
197
+ :class="className"
198
+ type="button"
199
+ @click="onReport"
200
+ >
201
+ <UiIcon
202
+ name="flag"
203
+ size="16"
204
+ />
205
+ {{ t('report') }}
206
+ </button>
207
+ </template>
171
208
  </template>
172
209
  </UiDropdown>
173
210
  </div>
@@ -183,7 +220,7 @@
183
220
  </template>
184
221
 
185
222
  <script setup lang="ts">
186
- import { formatDate, ThumbSize } from '../../runtime';
223
+ import { formatDate, PLAYLIST_TYPE_ICON, PlaylistStep, PlaylistType, ThumbSize } from '../../runtime';
187
224
  import type { IPlaylistCard, IProfileData, IThumbUrls } from '../../types';
188
225
  import { AuthorizationApiService } from '../../services/api/authorization.service';
189
226
  import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
@@ -191,8 +228,12 @@ import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
191
228
  const props = defineProps<{
192
229
  card: IPlaylistCard
193
230
  topPlaylists?: boolean
194
- /** Без меню «три точки» — вместе с ним уходят избранное, share и report */
231
+ /** Без меню «три точки» — вместе с ним уходят подписка, share и report */
195
232
  noActions?: boolean
233
+ /** Кнопка подписки поверх обложки — список подписок в библиотеке */
234
+ showFollow?: boolean
235
+ /** Свой плейлист: приватность значком у названия, в меню — правка и удаление вместо подписки */
236
+ own?: boolean
196
237
  priority?: boolean
197
238
  }>();
198
239
 
@@ -216,21 +257,29 @@ const userProfileData = computed(() => useState<IProfileData>('profileData').val
216
257
 
217
258
  const { isAuthorized } = useUser(AuthorizationApiService);
218
259
 
219
- const { isFavorite, toggleFavorite } = usePlaylistCollections();
260
+ const { isFollowing, toggleFollow, toggle } = usePlaylistFollow();
220
261
 
221
- const favorite = computed(() => isFavorite(props.card.id));
262
+ const { openPlaylistEdit } = usePlaylistEdit();
222
263
 
223
- // Тем же кликом, что переключает коллекцию, меню закрывается. Если менять надпись сразу, она
264
+ const isPrivate = computed(() => props.card.playlistType === PlaylistType.Private);
265
+
266
+ const visibilityTitle = computed(() => isPrivate.value
267
+ ? t('playlist.private_playlist')
268
+ : t('playlist.public_playlist'));
269
+
270
+ const following = computed(() => isFollowing(props.card.id));
271
+
272
+ // Тем же кликом, что переключает подписку, меню закрывается. Если менять надпись сразу, она
224
273
  // дёргается прямо во время анимации закрытия, поэтому в меню живёт снапшот состояния — он
225
274
  // догоняет реальное только к следующему открытию, когда пункт всё равно перерисовывается с нуля.
226
- const shownFavorite = ref(favorite.value);
275
+ const shownFollowing = ref(following.value);
227
276
 
228
- const favoritesLabel = computed(() => shownFavorite.value
229
- ? t('video_actions.remove_favorites')
230
- : t('video_actions.add_favorites'));
277
+ const followLabel = computed(() => shownFollowing.value
278
+ ? t('unfollow')
279
+ : t('follow'));
231
280
 
232
281
  watch(() => dropdownRef.value?.open, (isOpen) => {
233
- if (isOpen) shownFavorite.value = favorite.value;
282
+ if (isOpen) shownFollowing.value = following.value;
234
283
  });
235
284
 
236
285
  const avatar = computed(() => {
@@ -261,9 +310,14 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
261
310
  </script>
262
311
 
263
312
  <style lang="scss">
313
+ // Ниже этой ширины в мета-строку не влезает «Updated» рядом с лайками, просмотрами и датой
314
+ $updated-word-width: 240px;
315
+
264
316
  .card-playlist {
317
+ position: relative;
265
318
  display: grid;
266
319
  width: 100%;
320
+ container-type: inline-size;
267
321
  align-items: start;
268
322
  align-content: start;
269
323
  border-radius: $radius-xl;
@@ -423,10 +477,26 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
423
477
  color: $playlist-card-empty-text;
424
478
  }
425
479
 
426
- .card-playlist__private {
480
+ .card-playlist__visibility {
481
+ display: grid;
482
+ flex-shrink: 0;
483
+ color: $playlist-card-visibility-icon;
484
+ }
485
+
486
+ /* Дропдаун красит свои пункты и иконки сам, поэтому селектор адресный — иначе «удалить» не красное */
487
+ .ui-dropdown__menu-items > .card-playlist__delete.ui-dropdown__item {
488
+ color: $playlist-card-delete-text;
489
+ }
490
+
491
+ .ui-dropdown__menu-items > .card-playlist__delete.ui-dropdown__item .ui-icon {
492
+ color: inherit;
493
+ }
494
+
495
+ .card-playlist__follow {
427
496
  position: absolute;
428
- right: 8px;
429
497
  top: 8px;
498
+ right: 8px;
499
+ z-index: $z-index-plus;
430
500
  }
431
501
 
432
502
  .card-playlist__tags {
@@ -467,6 +537,12 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
467
537
  color: $playlist-card-meta;
468
538
  }
469
539
 
540
+ @include to-cq($updated-word-width) {
541
+ .card-playlist__updated {
542
+ display: none;
543
+ }
544
+ }
545
+
470
546
  .card-playlist__avatar.--placeholder {
471
547
  @include font-xs;
472
548
 
@@ -7,6 +7,8 @@
7
7
  :card="item"
8
8
  :top-playlists="topPlaylists"
9
9
  :no-actions="noActions"
10
+ :show-follow="showFollow"
11
+ :own="own"
10
12
  :priority="priority && index === 0"
11
13
  @share="onShare"
12
14
  />
@@ -35,6 +37,10 @@ defineProps<{
35
37
  topPlaylists?: boolean
36
38
  /** Скрыть меню карточки (три точки: share/report) — например у редакционных подборок */
37
39
  noActions?: boolean
40
+ /** Кнопка подписки поверх обложки — список подписок в библиотеке */
41
+ showFollow?: boolean
42
+ /** Свои плейлисты: приватность значком у названия, в меню — правка и удаление */
43
+ own?: boolean
38
44
  priority?: boolean
39
45
  }>();
40
46
 
@@ -0,0 +1,92 @@
1
+ <!-- Коллекция видео («Избранное», «Смотреть позже»): счётчик, сортировка, сетка карточек -->
2
+ <template>
3
+ <div class="library-collection">
4
+ <UiSectionTitle
5
+ :title="title"
6
+ :count="total"
7
+ :icon="icon"
8
+ class="library-collection__title"
9
+ />
10
+
11
+ <SectionNavLinks
12
+ :items="sortCollections"
13
+ class="library-collection__sort"
14
+ theme="secondary"
15
+ />
16
+
17
+ <div
18
+ v-if="pageItems.length > 0"
19
+ class="library-collection__grid"
20
+ >
21
+ <CardVideo
22
+ v-for="(item, index) in pageItems"
23
+ :key="item.guid"
24
+ :card="item"
25
+ :priority="index < 4"
26
+ :loading="index < 4 ? 'eager' : 'lazy'"
27
+ />
28
+ </div>
29
+
30
+ <UiEmptyState
31
+ v-else
32
+ without-reset
33
+ />
34
+
35
+ <UiPagination
36
+ v-if="allPages > 1"
37
+ :key="`pagination-${allPages}`"
38
+ :total="allPages"
39
+ class="library-collection__pagination _pagination"
40
+ arrow-icon
41
+ />
42
+ </div>
43
+ </template>
44
+
45
+ <script setup lang="ts">
46
+ import { sortCollections } from '../../lib';
47
+ import type { IVideoCard } from '../../types';
48
+
49
+ const PER_PAGE = 24;
50
+
51
+ const props = defineProps<{
52
+ /** Ролики коллекции в порядке добавления, свежие первыми — «недавние» это сортировка по умолчанию */
53
+ items: IVideoCard[]
54
+ title: string
55
+ icon: string
56
+ }>();
57
+
58
+ const route = useRoute();
59
+
60
+ const sortedItems = computed(() => route.query['sort'] === 'az'
61
+ ? [ ...props.items ].sort((a, b) => a.title.localeCompare(b.title))
62
+ : props.items);
63
+
64
+ const total = computed(() => sortedItems.value.length);
65
+ const allPages = computed(() => Math.ceil(total.value / PER_PAGE));
66
+
67
+ const pageItems = computed(() => {
68
+ const page = Math.min(Math.max(Number(route.query['page']) || 1, 1), allPages.value || 1);
69
+ return sortedItems.value.slice((page - 1) * PER_PAGE, page * PER_PAGE);
70
+ });
71
+ </script>
72
+
73
+ <style lang="scss">
74
+ .library-collection__title {
75
+ margin-bottom: 24px;
76
+ }
77
+
78
+ .library-collection__sort {
79
+ margin-bottom: 32px;
80
+ }
81
+
82
+ .library-collection__grid {
83
+ @include video-card-grid;
84
+
85
+ position: relative;
86
+ }
87
+
88
+ .ui-pagination.library-collection__pagination {
89
+ margin-top: auto;
90
+ margin-bottom: 40px;
91
+ }
92
+ </style>
@@ -94,7 +94,7 @@ const props = defineProps<{
94
94
  }>();
95
95
 
96
96
  const emit = defineEmits<{
97
- (eventName: 'delete-playlist', eventValue: string): void
97
+ (eventName: 'delete-playlist', eventValue: { id: string, name?: string }): void
98
98
  (eventName: 'save-edit', eventValue: {
99
99
  id: string
100
100
  playlistName: string
@@ -135,16 +135,14 @@ const id = computed(() => selectedPlaylist.value?.id ?? (route.params[ 'playlist
135
135
 
136
136
  const playlistsData = useState<IPlaylistData>(`data-${AsyncData.PlaylistById}-${id.value}`);
137
137
 
138
- async function onDeleteClick() {
139
- try {
140
- const name = selectedPlaylist.value?.name;
138
+ // Сначала закрываем попап, потом сообщаем об удалении: снекбар с undo показывает владелец
139
+ // запроса (PlaylistMain), и всплыть он должен уже в макете, а не в закрывающемся диалоге.
140
+ function onDeleteClick() {
141
+ const playlistId = id.value || '';
142
+ const name = selectedPlaylist.value?.name;
141
143
 
142
- emit('delete-playlist', id.value || '');
143
- goBack();
144
- show('deleted', { icon: 'trash-bin', highlight: name });
145
- } catch {
146
- console.log('error delete');
147
- }
144
+ goBack();
145
+ emit('delete-playlist', { id: playlistId, name });
148
146
  }
149
147
 
150
148
  async function onSaveClick() {
@@ -10,7 +10,7 @@
10
10
  :loading-post-playlist="loadingPostPlaylist"
11
11
  :loading-user-playlists="loadingUserPlaylists"
12
12
  :loading-post-video="loadingPostVideo"
13
- :user-playlists="data"
13
+ :user-playlists="userPlaylists"
14
14
  @execute-user-playlists="getUserPlaylists"
15
15
  @save-video-to-playlist="saveVideoToPlaylist"
16
16
  />
@@ -35,12 +35,14 @@ import { PlaylistsApiService } from '../../services/api/playlists.service';
35
35
  import type { Language, PlaylistType } from '../../runtime';
36
36
  import { AsyncData } from '../../runtime';
37
37
  import { useI18n } from 'vue-i18n';
38
- import type { IPlaylistVideoFormType, IPlaylistShort } from '../../types';
38
+ import type { IPlaylistCard, IPlaylistVideoFormType, IPlaylistShort, PaginatedResponse } from '../../types';
39
39
 
40
40
  const { locale } = useI18n();
41
41
 
42
42
  const { isPlaylistAdd } = usePlaylistAdd();
43
43
  const { isPlaylistEditOpen } = usePlaylistEdit();
44
+ const { show } = useSnackbar();
45
+ const { isPendingDelete, markPendingDelete, unmarkPendingDelete } = usePlaylistDelete();
44
46
 
45
47
  const route = useRoute();
46
48
 
@@ -68,6 +70,11 @@ const {
68
70
  } = useApiAction(PlaylistsApiService.deletePlaylist);
69
71
  const { execute: executeRefresh, loading: loadingRefresh } = useApiAction(PlaylistsApiService.refreshPlaylist);
70
72
 
73
+ /** Плейлисты с ещё не отправленным удалением в попапе добавления не показываем. */
74
+ const userPlaylists = computed<PaginatedResponse<IPlaylistCard> | null>(() => data.value
75
+ ? { ...data.value, items: data.value.items.filter(item => !isPendingDelete(item.id)) }
76
+ : null);
77
+
71
78
  async function getUserPlaylists() {
72
79
  await executeUserPlaylists([locale.value as Language], AsyncData.GetPlaylists);
73
80
  }
@@ -107,9 +114,28 @@ async function deleteVideo(event: {
107
114
  }, locale.value as Language], `${AsyncData.GetPlaylistsVideo}-${event.playlistId}`);
108
115
  }
109
116
 
110
- async function deletePlaylist(event: string) {
111
- await executeDeletePlaylist([event]);
117
+ async function commitDeletePlaylist(id: string) {
118
+ await executeDeletePlaylist([id]);
112
119
  await executeUserPlaylists([locale.value as Language], AsyncData.GetPlaylists);
120
+ // Метку снимаем только после ответа: до него плейлист ещё в списке и всплыл бы обратно.
121
+ unmarkPendingDelete(id);
122
+ }
123
+
124
+ /**
125
+ * Удаление оптимистичное: карточка скрывается сразу, а запрос уходит, когда закроется снекбар,
126
+ * то есть окно отмены истекло. Undo просто снимает метку, и карточка возвращается на место.
127
+ * Удаления идут независимо друг от друга: у каждого свой id, поэтому несколько подряд
128
+ * не путаются, а рефетч после соседнего удаления не показывает уже «удалённые» плейлисты.
129
+ */
130
+ function deletePlaylist(event: { id: string, name?: string }) {
131
+ markPendingDelete(event.id);
132
+
133
+ show('deleted', {
134
+ icon: 'trash-bin',
135
+ highlight: event.name,
136
+ onAction: () => unmarkPendingDelete(event.id),
137
+ onDismiss: () => commitDeletePlaylist(event.id),
138
+ });
113
139
  }
114
140
 
115
141
  async function saveEditPlaylist(event: {
@@ -0,0 +1,38 @@
1
+ // @vitest-environment nuxt
2
+ import { describe, it, expect, beforeEach } from 'vitest';
3
+ import { usePlaylistDelete } from '../use-playlist-delete';
4
+
5
+ describe('usePlaylistDelete', () => {
6
+ beforeEach(() => {
7
+ useNuxtApp().payload.state = {};
8
+ });
9
+
10
+ it('метка скрывает плейлист и не дублируется', () => {
11
+ const { pendingIds, isPendingDelete, markPendingDelete } = usePlaylistDelete();
12
+
13
+ markPendingDelete('playlist-1');
14
+ markPendingDelete('playlist-1');
15
+
16
+ expect(pendingIds.value).toEqual([ 'playlist-1' ]);
17
+ expect(isPendingDelete('playlist-1')).toBe(true);
18
+ expect(isPendingDelete('playlist-2')).toBe(false);
19
+ });
20
+
21
+ it('снятие метки не задевает соседние удаления', () => {
22
+ const { pendingIds, markPendingDelete, unmarkPendingDelete } = usePlaylistDelete();
23
+
24
+ markPendingDelete('playlist-1');
25
+ markPendingDelete('playlist-2');
26
+ unmarkPendingDelete('playlist-1');
27
+
28
+ expect(pendingIds.value).toEqual([ 'playlist-2' ]);
29
+ });
30
+
31
+ it('снятие несуществующей метки ничего не ломает', () => {
32
+ const { pendingIds, unmarkPendingDelete } = usePlaylistDelete();
33
+
34
+ unmarkPendingDelete('playlist-1');
35
+
36
+ expect(pendingIds.value).toEqual([]);
37
+ });
38
+ });
@@ -0,0 +1,54 @@
1
+ // @vitest-environment nuxt
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
+ import { usePlaylistFollow } from '../use-playlist-follow';
4
+ import { useSnackbar } from '../use-snackbar';
5
+
6
+ describe('usePlaylistFollow', () => {
7
+ beforeEach(() => {
8
+ useNuxtApp().payload.state = {};
9
+ vi.useFakeTimers();
10
+ useSnackbar().resetSnackbar();
11
+ });
12
+
13
+ afterEach(() => {
14
+ vi.useRealTimers();
15
+ });
16
+
17
+ it('подписка добавляет плейлист и не показывает снекбар', () => {
18
+ const { follows, isFollowing, toggleFollow } = usePlaylistFollow();
19
+ const { snackbarText } = useSnackbar();
20
+
21
+ toggleFollow('playlist-1', 'Playlist name');
22
+
23
+ expect(follows.value).toEqual([ 'playlist-1' ]);
24
+ expect(isFollowing('playlist-1')).toBe(true);
25
+ expect(snackbarText.value).toBe('');
26
+ });
27
+
28
+ it('отписка показывает снекбар с кнопкой отмены', () => {
29
+ const { follows, isFollowing, toggleFollow } = usePlaylistFollow();
30
+ const { snackbarText, snackbarIcon, snackbarHighlight, snackbarActionText } = useSnackbar();
31
+
32
+ toggleFollow('playlist-1', 'Playlist name');
33
+ toggleFollow('playlist-1', 'Playlist name');
34
+
35
+ expect(follows.value).toEqual([]);
36
+ expect(isFollowing('playlist-1')).toBe(false);
37
+ expect(snackbarText.value).toBe('unfollowed');
38
+ expect(snackbarIcon.value).toBe('minus');
39
+ expect(snackbarHighlight.value).toBe('Playlist name');
40
+ expect(snackbarActionText.value).toBe('undo');
41
+ });
42
+
43
+ it('отмена возвращает подписку и не показывает новый снекбар', () => {
44
+ const { follows, toggleFollow } = usePlaylistFollow();
45
+ const { runSnackbarAction, snackbarText } = useSnackbar();
46
+
47
+ toggleFollow('playlist-2', 'Another name');
48
+ toggleFollow('playlist-2', 'Another name');
49
+ runSnackbarAction();
50
+
51
+ expect(follows.value).toEqual([ 'playlist-2' ]);
52
+ expect(snackbarText.value).toBe('');
53
+ });
54
+ });