Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@
</template>

<template #actions-end>
<VListTileAction class="action-icon px-1" @click.stop>
<transition name="fade">
<IconButton
icon="edit"
size="small"
:text="$tr('editTooltip')"
:disabled="copying"
@click.stop
@click="$emit('editTitleDescription')"
/>
</transition>
</VListTileAction>
<VListTileAction :aria-hidden="!active" class="action-icon px-1">
<Menu v-model="activated">
<template #activator="{ on }">
Expand Down Expand Up @@ -217,6 +229,7 @@
creatingCopies: 'Copying...',
copiedSnackbar: 'Copy operation complete',
undo: 'Undo',
editTooltip: 'Edit Title & Description',
/* eslint-enable kolibri/vue-no-unused-translations */
},
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<template>

<div>
<KModal
:title="$tr('editTitleDescription')"
:submitText="$tr('saveAction')"
:cancelText="$tr('cancelAction')"
data-test="edit-title-description-modal"
@submit="handleSave"
@cancel="close"
>
<KTextbox
v-model="title"
data-test="title-input"
autofocus
showInvalidText
:maxlength="200"
:label="$tr('titleLabel')"
:invalid="!!titleError"
:invalidText="titleError"
@input="titleError = ''"
@blur="validateTitle"
/>
<KTextbox
v-model="description"
data-test="description-input"
textArea
:maxlength="400"
:label="$tr('descriptionLabel')"
style="margin-top: 0.5em"
/>
</KModal>
</div>

</template>


<script>

import { mapActions, mapGetters } from 'vuex';
import { getTitleValidators, getInvalidText } from 'shared/utils/validation';

export default {
name: 'EditTitleDescriptionModal',
props: {
nodeId: {
type: String,
required: true,
},
},
data() {
return {
title: '',
description: '',
titleError: '',
};
},
computed: {
...mapGetters('contentNode', ['getContentNode']),
contentNode() {
return this.getContentNode(this.nodeId);
},
},
created() {
this.title = this.contentNode.title || '';
this.description = this.contentNode.description || '';
},
methods: {
...mapActions('contentNode', ['updateContentNode']),
validateTitle() {
this.titleError = getInvalidText(getTitleValidators(), this.title);
},
close() {
this.$emit('close');
},
handleSave() {
this.validateTitle();
if (this.titleError) {
return;
}

const { nodeId, title, description } = this;
this.updateContentNode({
id: nodeId,
title: title.trim(),
description: description.trim(),
});

this.$store.dispatch('showSnackbarSimple', this.$tr('editedTitleDescription'));
this.close();
},
},
$trs: {
editTitleDescription: 'Edit Title and Description',
titleLabel: 'Title',
descriptionLabel: 'Description',
saveAction: 'Save',
cancelAction: 'Cancel',
editedTitleDescription: 'Edited title and description',
},
};

</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { mount } from '@vue/test-utils';
import Vuex from 'vuex';
import EditTitleDescriptionModal from '../EditTitleDescriptionModal.vue';

const nodeId = 'test-id';

const node = {
id: nodeId,
title: 'test-title',
description: 'test-description',
};

let store;
let contentNodeActions;
let generalActions;

describe('EditTitleDescriptionModal', () => {
beforeEach(() => {
contentNodeActions = {
updateContentNode: jest.fn(),
};
generalActions = {
showSnackbarSimple: jest.fn(),
};
store = new Vuex.Store({
actions: generalActions,
modules: {
contentNode: {
namespaced: true,
actions: contentNodeActions,
getters: {
getContentNode: () => () => node,
},
},
},
});
});

test('smoke test', () => {
const wrapper = mount(EditTitleDescriptionModal, {
store,
propsData: {
nodeId,
},
});
expect(wrapper.isVueInstance()).toBe(true);
});

test('should display the correct title and description on first render', () => {
const wrapper = mount(EditTitleDescriptionModal, {
store,
propsData: {
nodeId,
},
});

expect(wrapper.find('[data-test="title-input"]').vm.$props.value).toBe(node.title);
expect(wrapper.find('[data-test="description-input"]').vm.$props.value).toBe(node.description);
});

test('should call updateContentNode on success submit', () => {
const wrapper = mount(EditTitleDescriptionModal, {
store,
propsData: {
nodeId,
},
});

wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit');
expect(contentNodeActions.updateContentNode).toHaveBeenCalled();
});

test('should call updateContentNode with the correct parameters on success submit', () => {
const wrapper = mount(EditTitleDescriptionModal, {
store,
propsData: {
nodeId,
},
});

const newTitle = 'new-title';
const newDescription = 'new-description';
wrapper.find('[data-test="title-input"]').vm.$emit('input', 'new-title');
wrapper.find('[data-test="description-input"]').vm.$emit('input', 'new-description');

wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit');

expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), {
id: nodeId,
title: newTitle,
description: newDescription,
});
});

test('should let update even if description is empty', () => {
const wrapper = mount(EditTitleDescriptionModal, {
store,
propsData: {
nodeId,
},
});

const newTitle = 'new-title';
wrapper.find('[data-test="title-input"]').vm.$emit('input', 'new-title');
wrapper.find('[data-test="description-input"]').vm.$emit('input', '');

wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit');

expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), {
id: nodeId,
title: newTitle,
description: '',
});
});

test('should validate title on blur', () => {
const wrapper = mount(EditTitleDescriptionModal, {
store,
propsData: {
nodeId,
},
});

wrapper.find('[data-test="title-input"]').vm.$emit('input', '');
wrapper.find('[data-test="title-input"]').vm.$emit('blur');

expect(wrapper.find('[data-test="title-input"]').vm.$props.invalidText).toBeTruthy();
});

test('should validate title on submit', () => {
const wrapper = mount(EditTitleDescriptionModal, {
store,
propsData: {
nodeId,
},
});

wrapper.find('[data-test="title-input"]').vm.$emit('input', '');
wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit');

expect(wrapper.find('[data-test="title-input"]').vm.$props.invalidText).toBeTruthy();
});

test("should show 'Edited title and description' on a snackbar on success submit", () => {
const wrapper = mount(EditTitleDescriptionModal, {
store,
propsData: {
nodeId,
},
});

wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit');
expect(generalActions.showSnackbarSimple).toHaveBeenCalledWith(
expect.anything(),
'Edited title and description'
);
});

test("should emit 'close' event on success submit", () => {
const wrapper = mount(EditTitleDescriptionModal, {
store,
propsData: {
nodeId,
},
});

wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit');
expect(wrapper.emitted().close).toBeTruthy();
});

test('should emit close event on cancel', () => {
const wrapper = mount(EditTitleDescriptionModal, {
store,
propsData: {
nodeId,
},
});

wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('cancel');
expect(wrapper.emitted().close).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@
@select="selected = [...selected, $event]"
@deselect="selected = selected.filter(id => id !== $event)"
@scroll="scroll"
@editTitleDescription="showTitleDescriptionModal"
/>
</DraggableRegion>
</VFadeTransition>
Expand Down Expand Up @@ -223,6 +224,11 @@
</ResourceDrawer>
</VLayout>

<EditTitleDescriptionModal
v-if="editTitleDescriptionModal"
:nodeId="editTitleDescriptionModal.nodeId"
@close="editTitleDescriptionModal = null"
/>
</VContainer>

</template>
Expand All @@ -235,6 +241,7 @@
import ContentNodeOptions from '../components/ContentNodeOptions';
import ResourceDrawer from '../components/ResourceDrawer';
import { RouteNames, viewModes, DraggableRegions, DraggableUniverses } from '../constants';
import EditTitleDescriptionModal from '../components/quickEdit/EditTitleDescriptionModal';
import NodePanel from './NodePanel';
import IconButton from 'shared/views/IconButton';
import ToolBar from 'shared/views/ToolBar';
Expand Down Expand Up @@ -264,6 +271,7 @@
Checkbox,
MoveModal,
DraggableRegion,
EditTitleDescriptionModal,
},
mixins: [titleMixin, routerMixin],
props: {
Expand All @@ -282,6 +290,7 @@
loadingAncestors: false,
elevated: false,
moveModalOpen: false,
editTitleDescriptionModal: null,
};
},
computed: {
Expand Down Expand Up @@ -683,6 +692,11 @@
trackViewMode(mode) {
this.$analytics.trackAction('general', mode);
},
showTitleDescriptionModal(nodeId) {
this.editTitleDescriptionModal = {
nodeId,
};
},
},
$trs: {
addTopic: 'New folder',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
@infoClick="goToNodeDetail(child.id)"
@topicChevronClick="goToTopic(child.id)"
@dblclick.native="onNodeDoubleClick(child)"
@editTitleDescription="$emit('editTitleDescription', child.id)"
/>
</template>
</VList>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,21 @@ export function getActivityDurationValidators() {
];
}

/**
* Get invalid text for a given value using a list of validators.
* @param {Array<Function>} validators
* @param {*} value Value to validate.
* @returns {String} Translated error message of the first validator that returns an error.
Empty string if value is valid.
*/
export function getInvalidText(validators, value) {
return (
validators
.map(validator => translateValidator(validator)(value))
.find(validation => validation !== true) || ''
);
}

// Node validation
// These functions return an array of error codes
export function getNodeTitleErrors(node) {
Expand Down