diff --git a/.eslintrc b/.eslintrc
index a17938ce3b..0ab1e29f68 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -8,6 +8,9 @@ rules:
vue/v-bind-style: 2
vue/v-on-style: 2
vue/html-quotes: [2, 'double']
+ require-path-exists/exists:
+ - 2
+ - webpackConfigPath: webpack.config.js
env:
browser: true
commonjs: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b9ca46c241..f6a49b5b48 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,11 +3,15 @@
## Upcoming release
#### Changes
* [[@jayoshih](https://github.com/jayoshih)] Updated README
-*
+* [[@jayoshih](https://github.com/jayoshih)] Vueified channel list page
#### Issues Resolved
* [#875 Make sure files referenced by both orphan and non-orphan nodes aren't deleted](https://github.com/learningequality/studio/issues/875)
-*
+* [#1120](https://github.com/learningequality/studio/issues/1120)
+* [#900](https://github.com/learningequality/studio/issues/900)
+* [#875 Make sure files referenced by both orphan and non-orphan nodes aren't deleted](https://github.com/learningequality/studio/issues/875)
+
+
## 2019-02-11 Release
#### Changes
diff --git a/__mocks__/vue.js b/__mocks__/vue.js
new file mode 100644
index 0000000000..1b4435843a
--- /dev/null
+++ b/__mocks__/vue.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+var vueIntl = require("vue-intl");
+
+// Just copy/pasted code from utils/translations.js here
+// as trying to import the actual file led to some errors
+
+var translations = window.ALL_MESSAGES || {}; // Set in django
+
+// Flatten translation dictionary
+var unnested_translations = {};
+Object.keys(translations).forEach(function (key) {
+ Object.keys(translations[key]).forEach(function(nestedKey) {
+ unnested_translations[key + "." + nestedKey] = translations[key][nestedKey];
+ });
+});
+
+Vue.use(vueIntl, {"defaultLocale": "en"});
+
+var currentLanguage = "en";
+if (global.languageCode) {
+ currentLanguage = global.languageCode;
+ Vue.setLocale(currentLanguage);
+}
+
+Vue.registerMessages(currentLanguage, unnested_translations);
+Vue.prototype.$tr = function $tr(messageId, args) {
+ const nameSpace = this.$options.name;
+ if (args) {
+ if (!Array.isArray(args) && typeof args !== 'object') {
+ logging.error(`The $tr functions take either an array of positional
+ arguments or an object of named options.`);
+ }
+ }
+ const defaultMessageText = this.$options.$trs[messageId];
+ const message = {
+ id: `${nameSpace}.${messageId}`,
+ defaultMessage: defaultMessageText,
+ };
+
+ return this.$formatMessage(message, args);
+};
+
+module.exports = Vue;
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/constants.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/constants.js
new file mode 100644
index 0000000000..670efdbbf7
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/constants.js
@@ -0,0 +1,19 @@
+export const ListTypes = {
+ EDITABLE: 'EDITABLE',
+ STARRED: 'STARRED',
+ VIEW_ONLY: 'VIEW_ONLY',
+ PUBLIC: 'PUBLIC',
+ CHANNEL_SETS: 'CHANNEL_SETS'
+};
+
+export const ChannelListUrls = {
+ [ListTypes.EDITABLE]: window.Urls.get_user_edit_channels(),
+ [ListTypes.STARRED]: window.Urls.get_user_bookmarked_channels(),
+ [ListTypes.VIEW_ONLY]: window.Urls.get_user_view_channels(),
+ [ListTypes.PUBLIC]: window.Urls.get_user_public_channels()
+}
+
+export const ChannelInvitationMapping = {
+ 'edit': ListTypes.EDITABLE,
+ 'view': ListTypes.VIEW_ONLY
+}
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/mixins.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/mixins.js
new file mode 100644
index 0000000000..5b6cb833f0
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/mixins.js
@@ -0,0 +1,59 @@
+import _ from 'underscore';
+import { mapGetters, mapActions, mapMutations, mapState } from 'vuex';
+import { createTranslator } from 'utils/i18n';
+import { dialog, alert } from 'edit_channel/utils/dialog';
+
+const channelStrings = createTranslator('ChannelStrings', {
+ unsavedChanges: "Unsaved Changes!",
+ unsavedChangesText: "Exiting now will undo any new changes. Are you sure you want to exit?",
+ dontSave: "Discard Changes",
+ keepOpen: "Keep Editing",
+ save: "Save",
+ errorChannelSave: "Error Saving Channel"
+});
+
+exports.setChannelMixin = {
+ computed: {
+ ...mapState('channel_list', ['activeChannel']),
+ ...mapState('channel_list', ['changed']),
+ channelStrings() {
+ return channelStrings;
+ }
+ },
+ methods: {
+ ...mapActions('channel_list', ['saveChannel']),
+ ...mapMutations('channel_list', {
+ setActiveChannel: 'SET_ACTIVE_CHANNEL',
+ cancelChanges: 'CANCEL_CHANNEL_CHANGES'
+ }),
+ setChannel: function (channelID) {
+ // Check for changes here when user switches or closes panel
+ if(this.changed && channelID !== this.activeChannel.id) {
+ dialog(this.channelStrings("unsavedChanges"), this.channelStrings("unsavedChangesText"), {
+ [this.channelStrings("dontSave")]: () => {
+ this.cancelChanges();
+ this.setActiveChannel(channelID);
+ },
+ [this.channelStrings("keepOpen")]:() => {},
+ [this.channelStrings("save")]: () => {
+ this.saveChannel().then(() => {
+ this.setActiveChannel(channelID);
+ }).catch( (error) => {
+ alert(this.channelStrings('errorChannelSave'), error.responseText || error);
+ });
+ },
+ }, null);
+ } else {
+ this.setActiveChannel(channelID);
+ }
+ }
+ }
+};
+
+exports.tabMixin = {
+ mounted() {
+ this.$nextTick(() => {
+ this.$refs.firstTab.focus();
+ });
+ }
+};
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelDeleteSection.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelDeleteSection.spec.js
new file mode 100644
index 0000000000..d2a60ad6d7
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelDeleteSection.spec.js
@@ -0,0 +1,27 @@
+import { mount } from '@vue/test-utils';
+import ChannelDeleteSection from './../views/ChannelDeleteSection.vue';
+import _ from 'underscore';
+import { localStore, mockFunctions } from './data.js';
+
+
+function makeWrapper(props = {}) {
+ return mount(ChannelDeleteSection, {
+ store: localStore
+ })
+}
+
+describe('channelDeleteSection', () => {
+ let wrapper;
+ beforeEach(() => {
+ wrapper = makeWrapper();
+ });
+
+ it('clicking delete channel should open a dialog warning', () => {
+ wrapper.find('.delete-channel').trigger('click');
+ expect(mockFunctions.deleteChannel).not.toHaveBeenCalled();
+
+ expect(document.querySelector('#dialog-box')).toBeTruthy();
+ wrapper.vm.deleteChannel(wrapper.channel);
+ expect(mockFunctions.deleteChannel).toHaveBeenCalled();
+ });
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelDetailsPanel.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelDetailsPanel.spec.js
new file mode 100644
index 0000000000..520a812c50
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelDetailsPanel.spec.js
@@ -0,0 +1,33 @@
+import { shallowMount } from '@vue/test-utils';
+import ChannelDetailsPanel from './../views/ChannelDetailsPanel.vue';
+import _ from 'underscore';
+import { localStore, mockFunctions } from './data.js';
+
+
+function makeWrapper(props = {}) {
+ let channel = {
+ id: 'test',
+ main_tree: "abc",
+ ...props
+ }
+ localStore.commit('channel_list/RESET_STATE', channel)
+ localStore.commit('channel_list/ADD_CHANNEL', channel)
+ localStore.commit('channel_list/SET_ACTIVE_CHANNEL', channel.id)
+ return shallowMount(ChannelDetailsPanel, {
+ store: localStore
+ })
+}
+
+describe('channelDetailsPanel', () => {
+ it('panel should set background as thumbnail', () => {
+ let wrapper = makeWrapper({'thumbnail_url': 'test.png'});
+ let panel = wrapper.find('#channel-preview-wrapper');
+ let expectedStyle = "background-image: url('test.png')";
+ expect(panel.attributes('style')).toContain('test.png');
+ });
+ it('panel should set background as default thumbnail for new channels', () => {
+ let wrapper = makeWrapper();
+ let panel = wrapper.find('#channel-preview-wrapper');
+ expect(panel.attributes('style')).toContain('kolibri_placeholder.png');
+ });
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelDownloadDropdown.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelDownloadDropdown.spec.js
new file mode 100644
index 0000000000..63f8fcde9d
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelDownloadDropdown.spec.js
@@ -0,0 +1,41 @@
+import { mount } from '@vue/test-utils';
+import ChannelDownloadDropdown from './../views/ChannelDownloadDropdown.vue';
+import _ from 'underscore';
+import { localStore, mockFunctions } from './data.js';
+
+
+function makeWrapper(props = {}) {
+ return mount(ChannelDownloadDropdown, {
+ store: localStore
+ })
+}
+
+const testChannel = { id: 'test', name: 'test' }
+localStore.commit('channel_list/ADD_CHANNEL', testChannel)
+
+
+describe('channelDownloadDropdown', () => {
+ let wrapper;
+ beforeEach(() => {
+ localStore.commit('channel_list/SET_ACTIVE_CHANNEL', testChannel.id);
+ mockFunctions.downloadChannelDetails.mockReset();
+ wrapper = makeWrapper();
+ });
+
+ it('clicking a download link should trigger a download', () => {
+ wrapper.find('li a').trigger('click');
+ expect(mockFunctions.downloadChannelDetails).toHaveBeenCalled();
+ });
+ it('requesting a format should trigger corresponding format download', () => {
+ function test(format) {
+ wrapper.vm.downloadDetails(format);
+ let args = mockFunctions.downloadChannelDetails.mock.calls[0][1];
+ expect(args.format).toEqual(format);
+ mockFunctions.downloadChannelDetails.mockReset();
+ }
+ test('pdf');
+ test('csv');
+ test('detailedPdf');
+ test('ppt');
+ });
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelEditor.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelEditor.spec.js
new file mode 100644
index 0000000000..9e3ed3f014
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelEditor.spec.js
@@ -0,0 +1,122 @@
+import { mount } from '@vue/test-utils';
+import ChannelEditor from './../views/ChannelEditor.vue';
+import { localStore, mockFunctions } from './data.js';
+require('handlebars/helpers'); // Needed for image uploader
+
+let testChannel = {
+ 'id': 'test',
+ 'name': 'channel',
+ 'description': "description",
+ 'language': 'en',
+}
+
+function makeWrapper(props = {}) {
+ let channel = {
+ ...testChannel,
+ ...props
+ };
+ localStore.commit('channel_list/RESET_STATE');
+ localStore.commit('channel_list/ADD_CHANNEL', channel);
+ localStore.commit('channel_list/SET_ACTIVE_CHANNEL', channel.id);
+ return mount(ChannelEditor, {
+ store: localStore
+ });
+}
+
+describe('channelEditor', () => {
+ let wrapper;
+ beforeEach(() => {
+ wrapper = makeWrapper();
+ });
+
+ describe('on load', () => {
+ it('changed is false', () => {
+ expect(wrapper.vm.changed).toBe(false);
+ });
+ it('thumbnail is shown', () => {
+ let thumbnail = "thumbnail.png";
+ let thumbnailWrapper = makeWrapper({'thumbnail_url': thumbnail});
+ expect(thumbnailWrapper.find('img').attributes('src')).toContain(thumbnail);
+ });
+ it('default thumbnail is shown if channel has no thumbnail', () => {
+ expect(wrapper.find('img').attributes('src')).toContain('kolibri_placeholder.png')
+ });
+ it('.channel-name is set to channel.name', () => {
+ expect(wrapper.find('.channel-name').element.value).toEqual(testChannel.name);
+ });
+ it('.channel-description is set to channel.description', () => {
+ expect(wrapper.find('.channel-description').element.value).toEqual(testChannel.description);
+ });
+ it('#select-language is set to channel.language_id', () => {
+ expect(wrapper.find('#select-language').element.value).toEqual(testChannel.language);
+ });
+ });
+
+ describe('changes registered', () => {
+ beforeEach(() => {
+ localStore.commit('channel_list/SET_CHANGED', false);
+ });
+ it('setChannelThumbnail sets channel.thumbnail', () => {
+ let thumbnail = 'newthumbnail.png'
+ wrapper.vm.setChannelThumbnail(thumbnail, {'new encoding': thumbnail}, thumbnail, thumbnail)
+ expect(wrapper.vm.changed).toBe(true);
+ expect(wrapper.vm.channel.thumbnail).toEqual(thumbnail);
+ expect(wrapper.vm.channel.thumbnail_encoding).toEqual({'new encoding': thumbnail});
+ });
+ it('removeChannelThumbnail removes channel.thumbnail', () => {
+ let thumbnailWrapper = makeWrapper({'thumbnail': 'thumbnail.png', 'thumbnail_encoding': {'test': 'test'}})
+ thumbnailWrapper.vm.removeChannelThumbnail();
+ expect(wrapper.vm.changed).toBe(true);
+ expect(wrapper.vm.channel.thumbnail).toEqual("");
+ expect(wrapper.vm.channel.thumbnail_encoding).toEqual({});
+ });
+ it('typing in .channel-name sets channel.name', () => {
+ let newName = 'new channel name';
+ let nameInput = wrapper.find('.channel-name');
+ nameInput.element.value = newName;
+ nameInput.trigger('input');
+ nameInput.trigger('blur');
+ expect(wrapper.vm.changed).toBe(true);
+ expect(wrapper.vm.channel.name).toEqual(newName);
+ });
+ it('setting .channel-description sets channel.description', () => {
+ let newDescription = "new channel description";
+ let descriptionInput = wrapper.find('.channel-description');
+ descriptionInput.element.value = newDescription;
+ descriptionInput.trigger('input');
+ descriptionInput.trigger('blur');
+ expect(wrapper.vm.changed).toBe(true);
+ expect(wrapper.vm.channel.description).toEqual(newDescription);
+ });
+ it('setting #select-language sets channel.language_id', () => {
+ let newLanguage = "en";
+ let languageDropdown = wrapper.find('#select-language');
+ languageDropdown.element.value = newLanguage;
+ languageDropdown.trigger('input');
+ languageDropdown.trigger('blur');
+ expect(wrapper.vm.changed).toBe(true);
+ expect(wrapper.vm.channel.language).toEqual(newLanguage);
+ });
+ })
+
+ it('clicking CANCEL cancels edits', () => {
+ let nameInput = wrapper.find('.channel-name');
+ nameInput.element.value = 'new channel name';
+ nameInput.trigger('input');
+ nameInput.trigger('blur');
+
+ // Cancel changes
+ wrapper.find(".cancel-edits").trigger('click');
+ expect(wrapper.vm.changed).toBe(false);
+ expect(wrapper.vm.channel.name).toEqual('channel');
+ expect(wrapper.emitted().cancelEdit).toBeTruthy();
+ });
+
+ it('clicking SAVE saves edits', () => {
+ wrapper.find('form').trigger('submit');
+ expect(mockFunctions.saveChannel).toHaveBeenCalled();
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.emitted('submitChanges')).toBeTruthy();
+ });
+ });
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelInvitationItem.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelInvitationItem.spec.js
new file mode 100644
index 0000000000..a445ef1cf8
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelInvitationItem.spec.js
@@ -0,0 +1,56 @@
+import { mount } from '@vue/test-utils';
+import ChannelInvitationItem from './../views/ChannelInvitationItem.vue';
+import _ from 'underscore';
+import { localStore, mockFunctions } from './data.js';
+
+
+function makeWrapper(share_mode) {
+ let invitation = {
+ "id": "inviteid",
+ "share_mode": share_mode || "edit",
+ "sender": {
+ "first_name": "First",
+ "last_name": "Last"
+ }
+ };
+ localStore.commit('channel_list/SET_INVITATION_LIST', [invitation]);
+ return mount(ChannelInvitationItem, {
+ store: localStore,
+ propsData: {
+ invitationID: invitation.id
+ }
+ })
+}
+
+describe('channelInvitationItem', () => {
+ let wrapper;
+ beforeEach(() => {
+
+ wrapper = makeWrapper();
+ mockFunctions.declineInvitation.mockReset();
+ mockFunctions.acceptInvitation.mockReset();
+ });
+
+ it('clicking ACCEPT button should accept invitation', () => {
+ let acceptButton = wrapper.find('.accept-invitation');
+ acceptButton.trigger('click');
+ expect(mockFunctions.declineInvitation).not.toHaveBeenCalled();
+ expect(mockFunctions.acceptInvitation).toHaveBeenCalled();
+ });
+ it('clicking DECLINE button should decline invitation', () => {
+ let declineButton = wrapper.find('.decline-invitation');
+ declineButton.trigger('click');
+ expect(mockFunctions.acceptInvitation).not.toHaveBeenCalled();
+ expect(mockFunctions.declineInvitation).not.toHaveBeenCalled();
+
+ expect(document.querySelector('#dialog-box')).toBeTruthy();
+ wrapper.vm.declineInvitation(wrapper.vm.invitation);
+ expect(mockFunctions.declineInvitation).toHaveBeenCalled();
+ });
+ it('clicking X should dismiss invitation', () => {
+ expect(localStore.state.channel_list.invitations).toHaveLength(1);
+ wrapper.setData({accepted: true});
+ wrapper.find('.remove').trigger('click');
+ expect(localStore.state.channel_list.invitations).toHaveLength(0);
+ });
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelInvitationList.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelInvitationList.spec.js
new file mode 100644
index 0000000000..34f7b78f99
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelInvitationList.spec.js
@@ -0,0 +1,28 @@
+import { shallowMount } from '@vue/test-utils';
+import ChannelInvitationList from './../views/ChannelInvitationList.vue';
+import ChannelInvitationItem from './../views/ChannelInvitationItem.vue';
+import _ from 'underscore';
+import { Invitations, localStore, mockFunctions } from './data';
+
+
+function makeWrapper() {
+ return shallowMount(ChannelInvitationList, {
+ store: localStore
+ })
+}
+
+describe('channelInvitationList', () => {
+
+ it('loadChannelInvitationList should be called', () => {
+ let listWrapper = makeWrapper();
+ expect(mockFunctions.loadChannelInvitationList).toHaveBeenCalled();
+ })
+
+ it('list should load all invitations', () => {
+ let listWrapper = makeWrapper();
+ listWrapper.vm.$nextTick().then(() => {
+ let actualLength = listWrapper.findAll(ChannelInvitationItem).length;
+ expect(actualLength).toEqual(Invitations.length);
+ });
+ });
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelItem.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelItem.spec.js
new file mode 100644
index 0000000000..bf7438ee05
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelItem.spec.js
@@ -0,0 +1,43 @@
+import { shallowMount } from '@vue/test-utils';
+import ChannelItem from './../views/ChannelItem.vue';
+import _ from 'underscore';
+import { localStore } from './data.js';
+import { ListTypes } from './../constants';
+
+const testChannel = {
+ id: "test channel",
+ name: "test title",
+ modified: new Date()
+};
+
+function makeWrapper(props = {}) {
+ localStore.commit('channel_list/SET_CHANNEL_LIST', {channels: [testChannel], listType: ListTypes.EDITABLE});
+ return shallowMount(ChannelItem, {
+ store: localStore,
+ propsData: {
+ channelID: testChannel.id
+ }
+ })
+}
+
+describe('channelItem', () => {
+ let wrapper;
+ beforeEach(() => {
+ wrapper = makeWrapper();
+ });
+
+ it('should load channel information', () => {
+ expect(wrapper.text()).toEqual(expect.stringContaining(testChannel.name))
+ });
+ it('should set the store.activeChannel', () => {
+ let channel = wrapper.find('.channel-container-wrapper');
+ channel.trigger('click');
+ expect(wrapper.vm.activeChannel.id).toEqual(testChannel.id)
+ });
+ it('CTRL + click should open new window', () => {
+ global.open = jest.fn();
+ let event = { metaKey: true };
+ wrapper.vm.openChannel(event);
+ expect(global.open).toBeCalled();
+ });
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelList.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelList.spec.js
new file mode 100644
index 0000000000..a40b42f7c2
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelList.spec.js
@@ -0,0 +1,115 @@
+import { shallowMount } from '@vue/test-utils';
+import ChannelList from './../views/ChannelList.vue';
+import ChannelItem from './../views/ChannelItem.vue';
+import _ from 'underscore';
+import { Channels, localStore, mockFunctions } from './data';
+import { ListTypes } from './../constants';
+
+
+function makeWrapper(listType) {
+ return shallowMount(ChannelList, {
+ store: localStore,
+ propsData: {
+ listType: listType || ListTypes.EDITABLE
+ },
+ });
+}
+
+
+describe('channelList', () => {
+ let editListWrapper;
+ let starredListWrapper;
+ let publicListWrapper;
+ let viewonlyListWrapper;
+
+ beforeEach(() => {
+ editListWrapper = makeWrapper(ListTypes.EDITABLE);
+ starredListWrapper = makeWrapper(ListTypes.STARRED);
+ publicListWrapper = makeWrapper(ListTypes.PUBLIC);
+ viewonlyListWrapper = makeWrapper(ListTypes.VIEW_ONLY);
+ });
+
+ describe('populating lists', () => {
+ function testAll(test) {
+ test(editListWrapper);
+ test(starredListWrapper);
+ test(publicListWrapper);
+ test(viewonlyListWrapper);
+ }
+
+ it('loadChannelList should be called', () => {
+ expect(mockFunctions.loadChannelList).toHaveBeenCalled();
+ })
+
+ it('channels are shown in their respective lists', () => {
+ function test(wrapper) {
+ let channels = wrapper.findAll(ChannelItem);
+ let expectedLength = _.where(Channels, {[wrapper.props('listType')]: true}).length;
+ expect(channels).toHaveLength(expectedLength);
+ }
+ testAll(test);
+ })
+
+ it('new channels are added to the edit list', () => {
+ let newchannel = {"id": "new", "name": "test"};
+ localStore.commit('channel_list/SUBMIT_CHANNEL', newchannel);
+ function test(wrapper) {
+ let match = _.find(wrapper.vm.listChannels, {id: "new"});
+ if(wrapper.props('listType') === ListTypes.EDITABLE) {
+ expect(match).toBeTruthy();
+ } else {
+ expect(match).toBeUndefined();
+ }
+ }
+ testAll(test);
+ })
+
+ it('channel edits are reflected across lists', () => {
+ let edittedChannel = {"id": "channel5", "name": "test"};
+ localStore.commit('channel_list/SUBMIT_CHANNEL', edittedChannel);
+ function test(wrapper) {
+ let match = _.find(wrapper.vm.listChannels, {id: "channel5"});
+
+ // If a channel is part of a list, make sure the name change has been registered
+ if(match) {
+ expect(match.name).toBe("test");
+ }
+ }
+ testAll(test);
+ })
+
+ it('starred channels are added to the starred list', () => {
+ let channel = localStore.state.channel_list.channels[0];
+ channel.STARRED = true;
+ function test(wrapper) {
+ let match = _.find(wrapper.vm.listChannels, {id: channel.id});
+
+ // Make sure starred channels are added to the starred list
+ // Otherwise, make sure the STARRED property has been updated
+ if(wrapper.props("listType") === ListTypes.STARRED) {
+ expect(match).toBeTruthy();
+ } else if(match) {
+ expect(match.STARRED).toBe(true);
+ }
+ }
+ testAll(test);
+ })
+
+ it('unstarred channels are removed from the starred list', () => {
+ let channel = _.find(localStore.state.channel_list.channels, {STARRED: true});
+ channel.STARRED = false;
+ function test(wrapper) {
+ let match = _.find(wrapper.vm.listChannels, {id: channel.id});
+
+ // Make sure unstarred channels are removed from the starred list
+ // Otherwise, make sure the STARRED property has been updated
+ if(wrapper.props("listType") === ListTypes.STARRED) {
+ expect(match).toBeUndefined();
+ } else if(match) {
+ expect(match.STARRED).toBe(false);
+ }
+ }
+ testAll(test);
+ })
+ })
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelListPage.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelListPage.spec.js
new file mode 100644
index 0000000000..31d43ad727
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelListPage.spec.js
@@ -0,0 +1,43 @@
+import { shallowMount } from '@vue/test-utils';
+import ChannelListPage from './../views/ChannelListPage.vue';
+import ChannelDetailsPanel from './../views/ChannelDetailsPanel.vue';
+import { ListTypes } from './../constants';
+import { localStore } from './data'
+import _ from 'underscore';
+
+function makeWrapper(props = {}) {
+ return shallowMount(ChannelListPage, {
+ store: localStore
+ })
+}
+
+describe('channelListPage', () => {
+ let wrapper;
+ beforeEach(() => {
+ wrapper = makeWrapper();
+ });
+ it('on LOAD all list types have a tab', () => {
+ let expectedTabLength = _.values(ListTypes).length;
+ expect(wrapper.findAll("#manage-channel-nav li")).toHaveLength(expectedTabLength);
+ })
+ it('on CLICK tab must navigate to corresponding list', () => {
+ let results = wrapper.findAll("#manage-channel-nav li");
+ _.each(_.range(0, results.length), (index) => {
+ let tab = results.at(index);
+ tab.trigger('click');
+ expect(tab.is('.active')).toBe(true);
+ expect(wrapper.vm.$tr(wrapper.vm.activeList)).toEqual(tab.text());
+ })
+ })
+ it('details panel should toggle when active channel is set/unset', () => {
+ let channel = {'id': 'id', 'channel': 'channel'};
+ wrapper.vm.$store.commit('channel_list/ADD_CHANNEL', channel);
+ expect(wrapper.find(ChannelDetailsPanel).exists()).toBe(false);
+ wrapper.vm.$store.commit('channel_list/SET_ACTIVE_CHANNEL', channel.id);
+ expect(wrapper.find(ChannelDetailsPanel).exists()).toBe(true);
+ wrapper.vm.$store.commit('channel_list/SET_ACTIVE_CHANNEL', null);
+ expect(wrapper.find(ChannelDetailsPanel).exists()).toBe(false);
+ wrapper.vm.$store.commit('channel_list/SET_ACTIVE_CHANNEL', "");
+ expect(wrapper.find(ChannelDetailsPanel).exists()).toBe(true);
+ })
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelListStore.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelListStore.spec.js
new file mode 100644
index 0000000000..c642809448
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelListStore.spec.js
@@ -0,0 +1,178 @@
+import { mount } from '@vue/test-utils';
+import _ from 'underscore';
+import store from './../vuex/store';
+import { ListTypes } from './../constants';
+import { Invitations } from './data';
+const Backbone = require('backbone');
+
+/*
+ TODO: there are some issues trying to mock jquery.ajax as it
+ throws a `TypeError: Cannot read property 'prototype' of undefined`
+ due to how it interacts with Backbone. Added TODOs where we'll need
+ to test
+*/
+
+describe('channelListStore', () => {
+ let channel;
+ beforeEach(() => {
+ channel = {'id': 'test', 'name': 'channel', 'main_tree': 'node'};
+ store.commit('channel_list/RESET_STATE');
+ })
+ afterEach(() => {
+ jest.clearAllMocks();
+ })
+
+ describe('channel actions', () => {
+ describe('loadChannelList', () => {
+ it('should get list of channels', () => {
+ _.each(_.values(ListTypes), (type) => {
+ store.dispatch('channel_list/loadChannelList', type);
+ // TODO: make sure ChannelListUrls[type] endpoint is called
+ });
+ });
+ describe('adding channels to the store', () => {
+ let channels;
+ let payload1;
+ let payload2;
+
+ beforeEach(() => {
+ store.commit('channel_list/RESET_STATE');
+ channels = store.state.channel_list.channels;
+ payload1 = {
+ listType: ListTypes.EDITABLE,
+ channels: [{'id': 'channel 1'}, {'id': 'channel 2'}]
+ };
+ payload2 = {
+ listType: ListTypes.VIEW_ONLY,
+ channels: [{'id': 'channel 1'}, {'id': 'channel 3'}]
+ };
+ })
+
+ it('should merge the channels to one list', () => {
+ store.commit('channel_list/SET_CHANNEL_LIST', payload1);
+ expect(channels).toHaveLength(2);
+ store.commit('channel_list/SET_CHANNEL_LIST', payload2);
+ expect(channels).toHaveLength(3);
+ })
+
+ it('should mark relevant list types as true', () => {
+ store.commit('channel_list/SET_CHANNEL_LIST', payload1);
+ store.commit('channel_list/SET_CHANNEL_LIST', payload2);
+ expect(_.find(channels, {[ListTypes.EDITABLE]: true, id: "channel 1"})).toBeTruthy();
+ expect(_.find(channels, {[ListTypes.EDITABLE]: true, id: "channel 2"})).toBeTruthy();
+ expect(_.find(channels, {[ListTypes.EDITABLE]: false, id: "channel 3" })).toBeTruthy();
+ expect(_.find(channels, {[ListTypes.VIEW_ONLY]: true, id: "channel 1" })).toBeTruthy();
+ expect(_.find(channels, {[ListTypes.VIEW_ONLY]: false, id: "channel 2" })).toBeTruthy();
+ expect(_.find(channels, {[ListTypes.VIEW_ONLY]: true, id: "channel 3" })).toBeTruthy();
+ })
+ })
+ })
+
+ describe('star actions', () => {
+ it('addStar should post a star request', () => {
+ // store.dispatch('channel_list/addStar', channel)
+ // TODO: add_bookmark endpoint should be called
+ });
+ it('removeStar should post a star request', () => {
+ // store.dispatch('channel_list/removeStar', channel)
+ // TODO: remove_bookmark endpoint should be called
+ });
+ })
+ describe('channel edit actions', () => {
+ beforeEach(() => {
+ store.commit('channel_list/RESET_STATE');
+ store.commit('channel_list/ADD_CHANNEL', channel);
+ store.commit('channel_list/SET_ACTIVE_CHANNEL', channel.id);
+ })
+
+ it('saveChannel should save changes', () => {
+ store.commit('channel_list/SET_CHANNEL_NAME', 'new name');
+ store.dispatch('channel_list/saveChannel').then(() => {
+ // TODO: Need to mock endpoint to get rid of UnhandledPromiseRejectionWarning
+ // Needs to be patch as post will overwrite editors
+ expect(Backbone.sync.mock.calls[0][0]).toEqual('patch');
+ expect(Backbone.sync.mock.calls[0][1].attributes.name).toEqual('new name');
+ expect(store.state.channel_list.changed).toBe(false);
+ expect(store.state.channel_list.activeChannel.id).toBe("test");
+ });
+ });
+
+ it('deleteChannel should mark channel.deleted as true', () => {
+ store.commit('channel_list/SET_CHANNELSET_LIST', [{id:'channelset', channels:['test']}]);
+ store.dispatch('channel_list/deleteChannel', channel.id).then(() => {
+ expect(Backbone.sync.mock.calls[0][1].attributes.deleted).toBe(true);
+ expect(_.find(store.state.channel_list.channels, {'id': 'test'})).toBeFalsy();
+ expect(store.state.channel_list.channelSets[0].channels).toHaveLength(0);
+ });
+ });
+ });
+
+ it('loadNodeDetails should generate channel details', () => {
+ // store.dispatch('channel_list/loadNodeDetails', channel.main_tree);
+ // TODO: get_topic_details endpoint should be called
+ });
+
+ it('should start pdf download', () => {
+ // store.dispatch('channel_list/downloadChannelDetails', {id: 'test', format: 'detailedPDF'});
+ // TODO: make sure get_channel_details_pdf_endpoint endpoint is called
+ // store.dispatch('channel_list/downloadChannelDetails', {id: 'test', format: 'pdf'});
+ // TODO: make sure get_channel_details_pdf_endpoint endpoint is called
+ // store.dispatch('channel_list/downloadChannelDetails', {id: 'test', format: 'csv'});
+ // TODO: make sure get_channel_details_csv_endpoint endpoint is called
+ // store.dispatch('channel_list/downloadChannelDetails', {id: 'test', format: 'ppt'});
+ // TODO: make sure get_channel_details_ppt_endpoint endpoint is called
+ });
+ });
+
+ describe('channel set actions', () => {
+ beforeEach(() => {
+
+ })
+ it('loadChannelSetList should get list of channel sets', () => {
+ // store.dispatch('channel_list/loadChannelSetList');
+ // TODO: get_user_channel_sets endpoint should be called
+ });
+ it('deleteChannelSet should delete the channel set', () => {
+ let channelSet = {'id': 'test'};
+ store.commit('channel_list/SET_CHANNELSET_LIST', [channelSet]);
+ store.dispatch('channel_list/deleteChannelSet', channelSet.id).then(() => {
+ expect(Backbone.sync.mock.calls[0][0]).toEqual('delete');
+ expect(Backbone.sync.mock.calls[0][1].attributes.id).toEqual(channelSet.id);
+ expect(_.find(store.state.channel_list.channelSets, {id: channelSet.id})).toBeFalsy();
+ });
+ });
+ });
+
+ describe('invitation actions', () => {
+ beforeEach(() => {
+ store.commit('channel_list/RESET_STATE');
+ })
+ it('loadChannelInvitationList should get list of invitations', () => {
+ // store.dispatch('channel_list/loadChannelInvitationList');
+ // TODO: get_user_pending_channels endpoint should be called
+ });
+ it('acceptInvitation should accept the invitation', () => {
+ // _.each(Invitations, (invitation) => {
+ // store.dispatch('channel_list/acceptInvitation', invitation).then(() => {
+ // TODO: accept_channel_invite endpoint should be called
+ // TODO: Make sure proper edit/view access is given
+ // TODO: Make sure channels are added to correct lists
+ // jest.clearAllMocks();
+ // });
+ // });
+ });
+
+ it('declineInvitation should decline the invitation', () => {
+ _.each(Invitations, (invitation) => {
+ store.commit('channel_list/SET_INVITATION_LIST', [invitation]);
+ store.dispatch('channel_list/declineInvitation', invitation.id).then(() => {
+ // TODO: Need to mock endpoint to get rid of UnhandledPromiseRejectionWarning
+ expect(Backbone.sync.mock.calls[0][0]).toEqual('delete');
+ expect(Backbone.sync.mock.calls[0][1].attributes.id).toEqual(invitation.id);
+ jest.clearAllMocks();
+ });
+ })
+ });
+ });
+
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelMetadataSection.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelMetadataSection.spec.js
new file mode 100644
index 0000000000..43c5d8c3e9
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelMetadataSection.spec.js
@@ -0,0 +1,51 @@
+import { shallowMount } from '@vue/test-utils';
+import ChannelMetadataSection from './../views/ChannelMetadataSection.vue';
+import _ from 'underscore';
+import { localStore, mockFunctions } from './data.js';
+import { ListTypes } from './../constants';
+import State from 'edit_channel/state';
+
+State.current_user = {'id': 'testuser'}
+
+const testChannel = {
+ 'id': 'test',
+ 'name': 'channel',
+ 'created': new Date(),
+}
+function makeWrapper(props = {}) {
+ localStore.commit('channel_list/RESET_STATE');
+ let channel = {
+ ...testChannel,
+ ...props
+ }
+ localStore.commit('channel_list/ADD_CHANNEL', channel);
+ localStore.commit('channel_list/SET_ACTIVE_CHANNEL', channel.id);
+ return shallowMount(ChannelMetadataSection, {
+ store: localStore
+ })
+}
+
+describe('channelMetadataSection', () => {
+ it('channels with edit access should show EDIT DETAILS button', () => {
+ let wrapper = makeWrapper({editors: ['testuser']});
+ expect(wrapper.find('#edit-details').exists()).toBe(true);
+ });
+ it('channels with view only access should hide EDIT DETAILS button', () => {
+ let wrapper = makeWrapper({editors: []});
+ expect(wrapper.find('#edit-details').exists()).toBe(false);
+ });
+ it('ricecooker channels should hide EDIT DETAILS button', () => {
+ let wrapper = makeWrapper({editors: ['testuser'], ricecooker_version: 'rice'});
+ expect(wrapper.find('#edit-details').exists()).toBe(false);
+ });
+ it('OPEN CHANNEL button should link to edit mode for editable channels', () => {
+ let wrapper = makeWrapper({editors: ['testuser']});
+ let openButton = wrapper.find('#open-channel');
+ expect(openButton.attributes('href')).toEqual('channel');
+ });
+ it('OPEN CHANNEL button should link to view only mode for view only channels', () => {
+ let wrapper = makeWrapper({editors: []});
+ let openButton = wrapper.find('#open-channel');
+ expect(openButton.attributes('href')).toEqual('channel_view_only');
+ });
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelSetItem.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelSetItem.spec.js
new file mode 100644
index 0000000000..36d59680b2
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelSetItem.spec.js
@@ -0,0 +1,53 @@
+import { shallowMount } from '@vue/test-utils';
+import ChannelSetItem from './../views/ChannelSetItem.vue';
+import { localStore, mockFunctions } from './data.js';
+
+function makeWrapper(props = {}) {
+ let channelSet = {
+ id: 'channel-set-id',
+ name: "test title",
+ channels: ['test'],
+ secret_token: {
+ display_token: 'test-test'
+ }
+ }
+
+ localStore.commit('channel_list/ADD_CHANNEL', {'id': 'test'})
+ localStore.commit('channel_list/SET_CHANNELSET_LIST', [channelSet])
+
+ return shallowMount(ChannelSetItem, {
+ store: localStore,
+ propsData: {
+ channelSetID: channelSet.id
+ }
+ })
+}
+
+describe('channelSetItem', () => {
+ let wrapper;
+ beforeEach(() => {
+ wrapper = makeWrapper();
+ });
+
+ it('should show correct number of channels', () => {
+ let metadata = wrapper.find('.channel-metadata')
+ expect(metadata.text()).toEqual(expect.stringContaining("1 Channel"));
+ });
+ it('on DELETE click, dialog should be shown', () => {
+ wrapper.find('.delete-channelset').trigger('click')
+ expect(mockFunctions.deleteChannelSet).not.toHaveBeenCalled();
+ expect(document.querySelector('#dialog-box')).toBeTruthy();
+ });
+ it('on DELETE, channel set should be deleted', () => {
+ wrapper.vm.deleteChannelSet(wrapper.vm.channelSet);
+ expect(mockFunctions.deleteChannelSet).toHaveBeenCalled();
+ });
+ it('on channel delete, channel should be removed from channel', () => {
+ localStore.commit('channel_list/REMOVE_CHANNEL', 'test');
+ expect(wrapper.vm.channelSet.channels).not.toContain('test');
+ });
+ it('on click should open channel set modal', () => {
+ wrapper.find('.channel-container-wrapper').trigger('click');
+ // TODO: check for channel_set/views/ChannelSetModal
+ });
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelSetList.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelSetList.spec.js
new file mode 100644
index 0000000000..dd422ebd9a
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelSetList.spec.js
@@ -0,0 +1,40 @@
+import { shallowMount } from '@vue/test-utils';
+import ChannelSetList from './../views/ChannelSetList.vue';
+import ChannelSetItem from './../views/ChannelSetItem.vue';
+import { ChannelSets, localStore, mockFunctions } from './data';
+import _ from 'underscore';
+require('handlebars/helpers'); // Needed for collection details modal
+
+function makeWrapper() {
+ return shallowMount(ChannelSetList, {
+ store: localStore
+ })
+}
+
+describe('channelSetList', () => {
+ let listWrapper;
+
+ beforeEach(() => {
+ listWrapper = makeWrapper();
+ })
+
+ it('loadChannelSetList should be called', () => {
+ expect(mockFunctions.loadChannelSetList).toHaveBeenCalled();
+ })
+
+ it('list should load all channel sets', () => {
+ listWrapper.vm.$nextTick().then(() => {
+ let actualLength = listWrapper.findAll(ChannelSetItem).length;
+ expect(actualLength).toEqual(ChannelSets.length);
+ });
+ });
+
+ it('CREATE should open channel set modal', () => {
+ listWrapper.find('#new-set').trigger('click');
+ // TODO: check for channel_set/views/ChannelSetModal
+ });
+ it('About Collections link should open information modal', () => {
+ listWrapper.find('#about-sets').trigger('click');
+ // TODO: check for information/views/ChannelSetInformationModalView
+ });
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelStar.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelStar.spec.js
new file mode 100644
index 0000000000..03a740c79a
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/channelStar.spec.js
@@ -0,0 +1,37 @@
+import { mount } from '@vue/test-utils';
+import ChannelStar from './../views/ChannelStar.vue';
+import _ from 'underscore';
+import { localStore, mockFunctions } from './data';
+import { ListTypes } from './../constants';
+
+
+function makeWrapper(starred) {
+ let channel = {
+ id: "test channel",
+ name: "test title",
+ modified: new Date(),
+ STARRED: starred
+ };
+ localStore.commit('channel_list/SET_CHANNEL_LIST', {channels: [channel], listType: ListTypes.EDITABLE});
+ return mount(ChannelStar, {
+ store: localStore,
+ propsData: {
+ channelID: channel.id
+ }
+ })
+}
+
+describe('channelStar', () => {
+
+ it('clicking the star should toggle the star', () => {
+ function test(starred) {
+ let wrapper = makeWrapper(starred);
+ let star = wrapper.find('a');
+ star.trigger('click');
+ }
+ test(true);
+ expect(mockFunctions.removeStar).toHaveBeenCalled();
+ test(false);
+ expect(mockFunctions.addStar).toHaveBeenCalled();
+ });
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/data.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/data.js
new file mode 100644
index 0000000000..25c1f8f0e3
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/data.js
@@ -0,0 +1,150 @@
+import { ListTypes } from '../constants';
+import Vue from 'vue';
+var mutations = require('edit_channel/channel_list/vuex/mutations');
+var getters = require('edit_channel/channel_list/vuex/getters');
+var actions = require('edit_channel/channel_list/vuex/actions');
+const Vuex = require('vuex');
+Vue.use(Vuex);
+
+export const Channels = [
+ {
+ "id": "channel1",
+ "name": "Editable Channel",
+ [ListTypes.STARRED]: false,
+ [ListTypes.EDITABLE]: true,
+ [ListTypes.PUBLIC]: false,
+ [ListTypes.VIEW_ONLY]: false
+ },
+ {
+ "id": "channel2",
+ "name": "View-Only Channel",
+ [ListTypes.STARRED]: false,
+ [ListTypes.EDITABLE]: false,
+ [ListTypes.PUBLIC]: false,
+ [ListTypes.VIEW_ONLY]: true
+ },
+ {
+ "id": "channel3",
+ "name": "Public Channel",
+ [ListTypes.STARRED]: false,
+ [ListTypes.EDITABLE]: false,
+ [ListTypes.PUBLIC]: true,
+ [ListTypes.VIEW_ONLY]: false
+ },
+ {
+ "id": "channel4",
+ "name": "Starred + Public Channel",
+ [ListTypes.STARRED]: true,
+ [ListTypes.EDITABLE]: false,
+ [ListTypes.PUBLIC]: true,
+ [ListTypes.VIEW_ONLY]: false
+ },
+ {
+ "id": "channel5",
+ "name": "Starred + Editable Channel",
+ [ListTypes.STARRED]: true,
+ [ListTypes.EDITABLE]: true,
+ [ListTypes.PUBLIC]: false,
+ [ListTypes.VIEW_ONLY]: false
+ },
+]
+
+export const Invitations = [
+ {
+ "id": "invitation1",
+ "share_mode": "edit",
+ "sender": {
+ "first_name": "First",
+ "last_name": "Last"
+ }
+ },
+ {
+ "id": "invitation2",
+ "share_mode": "view",
+ "sender": {
+ "first_name": "First",
+ "last_name": "Last"
+ }
+ }
+]
+
+export const ChannelSets = [
+ {
+ id: 'channelSet1',
+ name: "test title",
+ channels: ['test'],
+ secret_token: {
+ display_token: 'test-test'
+ }
+ },
+ {
+ id: 'channelSet2',
+ name: "test title",
+ channels: ['test'],
+ secret_token: {
+ display_token: 'test-test'
+ }
+ }
+]
+
+export const mockFunctions = {
+ downloadChannelDetails: jest.fn(),
+ addStar: jest.fn(),
+ removeStar: jest.fn(),
+ loadChannelInvitationList: jest.fn(),
+ loadChannelList: jest.fn(),
+ acceptInvitation: jest.fn(),
+ declineInvitation: jest.fn(),
+ deleteChannel: jest.fn(),
+ loadNodeDetails: jest.fn(),
+ deleteChannelSet: jest.fn(),
+ loadChannelSetList: jest.fn(),
+ saveChannel: jest.fn()
+}
+
+export const localStore = new Vuex.Store({
+ modules: {
+ "channel_list": {
+ namespaced: true,
+ state: {
+ channels: [],
+ activeChannel: null,
+ changed: false,
+ channelChanges: {},
+ channelSets: [],
+ invitations: []
+ },
+ getters: getters,
+ mutations: {
+ ...mutations,
+ SET_CHANNEL_LIST: (state, payload) => {
+ state.channels = payload.channels;
+ }
+ },
+ actions: {
+ ...actions,
+ loadChannelInvitationList: (context) => {
+ context.commit('SET_INVITATION_LIST', Invitations);
+ mockFunctions.loadChannelInvitationList();
+ },
+ loadChannelList: (context) => {
+ context.commit('SET_CHANNEL_LIST', {channels: Channels});
+ mockFunctions.loadChannelList();
+ },
+ loadChannelSetList: (context) => {
+ context.commit('SET_CHANNELSET_LIST', ChannelSets);
+ mockFunctions.loadChannelSetList();
+ },
+ addStar: mockFunctions.addStar,
+ removeStar: mockFunctions.removeStar,
+ downloadChannelDetails: mockFunctions.downloadChannelDetails,
+ acceptInvitation: mockFunctions.acceptInvitation,
+ declineInvitation: mockFunctions.declineInvitation,
+ deleteChannel: mockFunctions.deleteChannel,
+ loadNodeDetails: mockFunctions.loadNodeDetails,
+ deleteChannelSet: mockFunctions.deleteChannelSet,
+ saveChannel: mockFunctions.saveChannel
+ }
+ }
+ }
+})
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/lookInsideView.spec.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/lookInsideView.spec.js
new file mode 100644
index 0000000000..1fb7bca18a
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/tests/lookInsideView.spec.js
@@ -0,0 +1,22 @@
+import { shallowMount } from '@vue/test-utils';
+import LookInsideView from './../views/LookInsideView.vue';
+import { localStore, mockFunctions } from './data.js';
+
+
+function makeWrapper(props = {}) {
+ return shallowMount(LookInsideView, {
+ store: localStore,
+ propsData: {
+ nodeID: "abc",
+ channel: {}
+ }
+ })
+}
+
+describe('lookInsideView', () => {
+ it('should load details when rendered', () => {
+ let wrapper = makeWrapper();
+ // TODO: Need to mock endpoint to get rid of UnhandledPromiseRejectionWarning
+ expect(mockFunctions.loadNodeDetails).toHaveBeenCalled()
+ });
+});
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/utils.js b/contentcuration/contentcuration/static/js/edit_channel/channel_list/utils.js
new file mode 100644
index 0000000000..f66a1befa7
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/utils.js
@@ -0,0 +1,34 @@
+
+import Models from 'edit_channel/models';
+import { ListTypes } from './constants';
+import State from 'edit_channel/state';
+import _ from 'underscore';
+
+const ListValues = _.values(ListTypes)
+export function prepChannel(channel) {
+ // Set all channel list attributes so vue will listen to them
+ _.each(ListValues, (type) => {
+ channel[type] = false;
+ });
+}
+
+export function getDefaultChannel() {
+ return {
+ name: "",
+ description: "",
+ editors: [State.current_user.id],
+ pending_editors: [],
+ language: State.preferences && State.preferences.language,
+ content_defaults: State.preferences,
+ thumbnail: "",
+ thumbnail_encoding: {}
+ }
+}
+
+export function getBackboneChannel(channel) {
+ return new Models.ChannelModel(channel);
+}
+
+export function getChannelSetModel(channelSet) {
+ return new Models.ChannelSetModel(channelSet);
+}
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/views/ChannelDeleteSection.vue b/contentcuration/contentcuration/static/js/edit_channel/channel_list/views/ChannelDeleteSection.vue
new file mode 100644
index 0000000000..9632412788
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/views/ChannelDeleteSection.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/contentcuration/contentcuration/static/js/edit_channel/channel_list/views/ChannelDetailsPanel.vue b/contentcuration/contentcuration/static/js/edit_channel/channel_list/views/ChannelDetailsPanel.vue
new file mode 100644
index 0000000000..d11cf07236
--- /dev/null
+++ b/contentcuration/contentcuration/static/js/edit_channel/channel_list/views/ChannelDetailsPanel.vue
@@ -0,0 +1,180 @@
+
+
+ {{channel.description}}
+
+ {{ $tr('created', {'date': $formatDate(channel.created, {day:'numeric', month:'short', 'year':'numeric'})}) }}
+
+
+ {{ $tr('published', {'date': $formatDate(channel.last_published, {day:'numeric', month:'short', 'year':'numeric'})}) }}
+
+ {{channelSet.description}}
+
+
+ {{channel.name}}
+ {{channel.name}}
+
+
+ {{ $tr("openChannel") }}
+ {{ $tr("editDetails") }}
+
+ {{channelSet.name}}
+
- {{formatMessage (intlGet 'messages.created')}} {{formatDate channel.created day="numeric" month="short" year="numeric"}} - | - {{#if channel.published}} - {{formatMessage (intlGet 'messages.last_published')}} {{formatDate channel.last_published day="numeric" month="short" year="numeric"}} - {{else}}{{formatMessage (intlGet 'messages.unpublished')}} - {{/if}} -
-{{channel.description}}
- {{/if}} -{{formatMessage (intlGet 'messages.delete_prompt')}}
- {{formatMessage (intlGet 'messages.delete_channel')}} -{{channel.name}}
- -{{channel.description}}
-{{channelset.description}}
-