Merged in feature/duplicate-content-blocks-MS-570-29-09-2022 (pull request #120)

Feature/duplicate content blocks MS-570 29 09 2022

Approved-by: Lorenz Padberg
This commit is contained in:
Ramon Wenger 2022-10-12 15:42:19 +00:00
commit ecf01971b0
17 changed files with 520 additions and 60 deletions

View File

@ -0,0 +1,62 @@
import {getMinimalMe} from '../../../support/helpers';
describe('Duplicate Content Block', () => {
beforeEach(() => {
cy.setup();
});
it.skip('works', () => {
// todo: does not work right now, as the cache does not seem to update for the 'inEditMode' local query. Need to
// make this work for the test to work right.
const operations = {
MeQuery: getMinimalMe({isTeacher: true}),
UpdateLastModule: {},
ModuleEditModeQuery: {
module: {
}
},
AssignmentQuery: {
assignment: {
title: 'Ein Assignment',
assignment: 'Eine Beschreibung'
}
},
ModuleDetailsQuery: {
module: {
chapters: [
{
contentBlocks: [
{
type: 'normal',
title: 'Hello there',
contents: [
{
type: 'text_block',
value: {
text: '<p>Asdf</p>'
}
},
{
type: 'assignment',
value: {
title: 'Ein Auftrag',
assignment: 'Eine Beschreibung',
id: 'abcd'
}
}
]
}
]
}
]
}
}
};
cy.mockGraphqlOps({
operations
});
cy.visit('/module/some-module');
cy.getByDataCy('toggle-editing').click();
});
});

View File

@ -61,6 +61,14 @@ describe('Custom Content Block', () => {
cy.log('Opening More Menu'); cy.log('Opening More Menu');
cy.getByDataCy('more-options-link').click(); cy.getByDataCy('more-options-link').click();
cy.log('Duplicating Content Block');
cy.getByDataCy('duplicate-content-block-link').click();
cy.get('.content-block').should('have.length', 2);
cy.log('Opening More Menu');
cy.getByDataCy('more-options-link').click();
// check if content block is still there // check if content block is still there
cy.log('Deleting Content Block'); cy.log('Deleting Content Block');
cy.getByDataCy('delete-content-block-link').click(); cy.getByDataCy('delete-content-block-link').click();

View File

@ -11,14 +11,28 @@
> >
<div <div
class="block-actions" class="block-actions"
v-if="canEditContentBlock && editMode" v-if="canEditModule && !isInstrumentBlock"
> >
<user-widget <user-widget
v-bind="me" v-bind="me"
class="block-actions__user-widget content-block__user-widget" class="block-actions__user-widget content-block__user-widget"
v-if="isMine"
/> />
<more-options-widget> <more-options-widget>
<li class="popover-links__link"> <li
class="popover-links__link"
v-if="!isInstrumentBlock"
>
<popover-link
data-cy="duplicate-content-block-link"
text="Duplizieren"
@link-action="duplicateContentBlock(contentBlock)"
/>
</li>
<li
class="popover-links__link"
v-if="isMine"
>
<popover-link <popover-link
data-cy="delete-content-block-link" data-cy="delete-content-block-link"
text="Löschen" text="Löschen"
@ -26,7 +40,10 @@
/> />
</li> </li>
<li class="popover-links__link"> <li
class="popover-links__link"
v-if="isMine"
>
<popover-link <popover-link
text="Bearbeiten" text="Bearbeiten"
@link-action="editContentBlock(contentBlock)" @link-action="editContentBlock(contentBlock)"
@ -83,13 +100,14 @@
import CHAPTER_QUERY from '@/graphql/gql/queries/chapterQuery.gql'; import CHAPTER_QUERY from '@/graphql/gql/queries/chapterQuery.gql';
import DELETE_CONTENT_BLOCK_MUTATION from '@/graphql/gql/mutations/deleteContentBlock.gql'; import DELETE_CONTENT_BLOCK_MUTATION from '@/graphql/gql/mutations/deleteContentBlock.gql';
import DUPLICATE_CONTENT_BLOCK_MUTATION from '@/graphql/gql/mutations/duplicateContentBlock.gql';
import me from '@/mixins/me'; import me from '@/mixins/me';
import {hidden} from '@/helpers/visibility'; import {hidden} from '@/helpers/visibility';
import {CONTENT_TYPE} from '@/consts/types'; import {CONTENT_TYPE} from '@/consts/types';
import PopoverLink from '@/components/ui/PopoverLink'; import PopoverLink from '@/components/ui/PopoverLink';
import {removeAtIndex} from '@/graphql/immutable-operations'; import {insertAtIndex, removeAtIndex} from '@/graphql/immutable-operations';
import {EDIT_CONTENT_BLOCK_PAGE} from '@/router/module.names'; import {EDIT_CONTENT_BLOCK_PAGE} from '@/router/module.names';
import {instrumentCategory} from '@/helpers/instrumentType'; import {instrumentCategory} from '@/helpers/instrumentType';
@ -163,7 +181,10 @@
return {}; return {};
}, },
canEditContentBlock() { canEditContentBlock() {
return this.contentBlock.mine && !this.contentBlock.indent; return this.isMine && !this.contentBlock.indent;
},
isMine() {
return this.contentBlock.mine;
}, },
contentBlocksWithContentLists() { contentBlocksWithContentLists() {
/* /*
@ -218,6 +239,36 @@
}, },
}, },
methods: { methods: {
duplicateContentBlock({id}) {
const parent = this.parent;
this.$apollo.mutate({
mutation: DUPLICATE_CONTENT_BLOCK_MUTATION,
variables: {
input: {
id,
},
},
update(store, {data: {duplicateContentBlock: {contentBlock}}}) {
if (contentBlock) {
const query = CHAPTER_QUERY;
const variables = {
id: parent.id,
};
const {chapter} = store.readQuery({query, variables});
const index = chapter.contentBlocks.findIndex(contentBlock => contentBlock.id === id);
const contentBlocks = insertAtIndex(chapter.contentBlocks, index, contentBlock);
const data = {
chapter: {
...chapter,
contentBlocks,
},
};
store.writeQuery({query, variables, data});
}
},
});
},
editContentBlock(contentBlock) { editContentBlock(contentBlock) {
const route = { const route = {
name: EDIT_CONTENT_BLOCK_PAGE, name: EDIT_CONTENT_BLOCK_PAGE,

View File

@ -138,7 +138,13 @@
import AddContentLink from '@/components/content-block-form/AddContentLink.vue'; import AddContentLink from '@/components/content-block-form/AddContentLink.vue';
import ContentElement from '@/components/content-block-form/ContentElement.vue'; import ContentElement from '@/components/content-block-form/ContentElement.vue';
import {moveToIndex, removeAtIndex, replaceAtIndex, swapElements} from '@/graphql/immutable-operations'; import {
insertAtIndex,
moveToIndex,
removeAtIndex,
replaceAtIndex,
swapElements
} from '@/graphql/immutable-operations';
import {CHOOSER, transformInnerContents} from '@/components/content-block-form/helpers.js'; import {CHOOSER, transformInnerContents} from '@/components/content-block-form/helpers.js';
import ContentElementActions from '@/components/content-block-form/ContentElementActions.vue'; import ContentElementActions from '@/components/content-block-form/ContentElementActions.vue';
@ -147,7 +153,7 @@
// TODO: refactor this file, it's huuuuuge! // TODO: refactor this file, it's huuuuuge!
interface ContentBlockFormData { interface ContentBlockFormData {
localContentBlock: any; localContentBlock: any;
} }
export default Vue.extend({ export default Vue.extend({
@ -201,60 +207,42 @@
update(index: number, element: any, parent?: number) { update(index: number, element: any, parent?: number) {
if (parent === undefined) { if (parent === undefined) {
// element is top level // element is top level
this.localContentBlock.contents = [ this.localContentBlock.contents = replaceAtIndex(this.localContentBlock.contents, index, element);
...this.localContentBlock.contents.slice(0, index),
element,
...this.localContentBlock.contents.slice(index + 1),
];
} else { } else {
const parentBlock = this.localContentBlock.contents[parent]; const parentBlock = this.localContentBlock.contents[parent];
this.localContentBlock.contents = [ const newElementContents = replaceAtIndex(parentBlock.contents, index, element);
...this.localContentBlock.contents.slice(0, parent), const newBlock = {
{ ...parentBlock,
...parentBlock, contents: newElementContents,
contents: [ };
...parentBlock.contents.slice(0, index), this.localContentBlock.contents = replaceAtIndex(this.localContentBlock.contents, parent, newBlock);
element,
...parentBlock.contents.slice(index + 1),
],
},
...this.localContentBlock.contents.slice(parent + 1),
];
} }
}, },
addBlock(afterOuterIndex: number, innerIndex?: number) { addBlock(afterOuterIndex: number, innerIndex?: number) {
if (innerIndex !== undefined) { if (innerIndex !== undefined) {
const block = this.localContentBlock.contents[afterOuterIndex]; const block = this.localContentBlock.contents[afterOuterIndex];
this.localContentBlock.contents = [ const element = {
...this.localContentBlock.contents.slice(0, afterOuterIndex), ...block,
{ contents: insertAtIndex(block.contents, innerIndex + 1, {
...block,
contents: [
...block.contents.slice(0, innerIndex + 1),
{
id: -1,
type: CHOOSER,
},
...block.contents.slice(innerIndex + 1),
],
},
...this.localContentBlock.contents.slice(afterOuterIndex + 1),
];
} else {
this.localContentBlock.contents = [
...this.localContentBlock.contents.slice(0, afterOuterIndex + 1),
{
id: -1, id: -1,
type: CHOOSER, type: CHOOSER,
includeListOption: true, }),
}, };
...this.localContentBlock.contents.slice(afterOuterIndex + 1),
]; this.localContentBlock.contents = replaceAtIndex(this.localContentBlock.contents, afterOuterIndex, element);
} else {
const element = {
id: -1,
type: CHOOSER,
includeListOption: true,
};
this.localContentBlock.contents = insertAtIndex(this.localContentBlock.contents, afterOuterIndex + 1, element);
} }
}, },
remove(outer: number, inner?: number, askForConfirmation = true) { remove(outer: number, inner?: number, askForConfirmation = true) {
if(askForConfirmation) { if (askForConfirmation) {
this.$modal.open('confirm') this.$modal.open('confirm')
.then(() => { .then(() => {
this.executeRemoval(outer, inner); this.executeRemoval(outer, inner);

View File

@ -9,6 +9,7 @@
@remove="$emit('remove', false)" @remove="$emit('remove', false)"
/> />
<!-- Content Forms --> <!-- Content Forms -->
<content-form-section <content-form-section
:title="title" :title="title"
@ -55,6 +56,16 @@
const AssignmentForm = () => import(/* webpackChunkName: "content-forms" */'@/components/content-forms/AssignmentForm'); const AssignmentForm = () => import(/* webpackChunkName: "content-forms" */'@/components/content-forms/AssignmentForm');
const TextForm = () => import(/* webpackChunkName: "content-forms" */'@/components/content-forms/TipTap.vue'); const TextForm = () => import(/* webpackChunkName: "content-forms" */'@/components/content-forms/TipTap.vue');
const SubtitleForm = () => import(/* webpackChunkName: "content-forms" */'@/components/content-forms/SubtitleForm'); const SubtitleForm = () => import(/* webpackChunkName: "content-forms" */'@/components/content-forms/SubtitleForm');
// readonly blocks
const Assignment = () => import(/* webpackChunkName: "content-forms" */'@/components/content-blocks/assignment/Assignment');
const SurveyBlock = () => import(/* webpackChunkName: "content-forms" */'@/components/content-blocks/SurveyBlock');
const Solution = () => import(/* webpackChunkName: "content-forms" */'@/components/content-blocks/Solution');
const ImageBlock = () => import(/* webpackChunkName: "content-forms" */'@/components/content-blocks/ImageBlock');
const Instruction = () => import(/* webpackChunkName: "content-forms" */'@/components/content-blocks/Instruction');
const ModuleRoomSlug = () => import(/* webpackChunkName: "content-forms" */'@/components/content-blocks/ModuleRoomSlug');
const CmsDocumentBlock = () => import(/* webpackChunkName: "content-forms" */'@/components/content-blocks/CmsDocumentBlock');
const ThinglinkBlock = () => import(/* webpackChunkName: "content-forms" */'@/components/content-blocks/ThinglinkBlock');
const InfogramBlock = () => import(/* webpackChunkName: "content-forms" */'@/components/content-blocks/InfogramBlock');
const CHOOSER = 'content-block-element-chooser-widget'; const CHOOSER = 'content-block-element-chooser-widget';
@ -91,6 +102,15 @@
AssignmentForm, AssignmentForm,
TextForm, TextForm,
SubtitleForm, SubtitleForm,
SurveyBlock,
Solution,
ImageBlock,
Instruction,
ModuleRoomSlug,
CmsDocumentBlock,
InfogramBlock,
ThinglinkBlock,
Assignment
}, },
computed: { computed: {
@ -153,7 +173,7 @@
}; };
case 'assignment': case 'assignment':
return { return {
component: 'assignment-form', component: element.id ? 'assignment' : 'assignment-form', // prevent editing of existing assignments
title: 'Aufgabe & Ergebnis', title: 'Aufgabe & Ergebnis',
icon: 'speech-bubble-icon', icon: 'speech-bubble-icon',
}; };
@ -163,6 +183,46 @@
title: 'Dokument', title: 'Dokument',
icon: 'document-icon', icon: 'document-icon',
}; };
case 'survey':
return {
component: 'survey-block',
title: 'Übung',
};
case 'solution':
return {
component: 'solution',
title: 'Lösung',
};
case 'image_block':
return {
component: 'image-block',
title: 'Bild',
};
case 'instruction':
return {
component: 'instruction',
title: 'Instruktion',
};
case 'module_room_slug':
return {
component: 'module-room-slug',
title: 'Raum',
};
case 'cms_document_block':
return {
component: 'cms-document-block',
title: 'Dokument',
};
case 'thinglink_block':
return {
component: 'thinglink-block',
title: 'Interaktive Grafik'
};
case 'infogram_block':
return {
component: 'infogram-block',
title: 'Interaktive Grafik'
};
} }
return { return {
component: CHOOSER, component: CHOOSER,

View File

@ -1,5 +1,28 @@
export const CHOOSER = 'content-block-element-chooser-widget'; export const CHOOSER = 'content-block-element-chooser-widget';
export const chooserFilter = value => value.type !== CHOOSER; export const chooserFilter = value => value.type !== CHOOSER;
export const USER_CONTENT_TYPES = ['subtitle', 'link_block', 'video_block', 'image_url_block', 'text_block', 'assignment', 'document_block'];
/*
Users can only edit certain types of contents, the rest can only be re-ordered. We only care about their id, we won't
send anything else to the server about them
*/
export const simplifyContents = (contents) => {
return contents.map(c => {
if (USER_CONTENT_TYPES.includes(c.type)) {
return c;
}
if (c.type === 'content_list_item') {
return {
...c,
contents: simplifyContents(c.contents)
};
}
return {
id: c.id,
type: 'readonly'
};
});
};
export const cleanUpContents = (contents) => { export const cleanUpContents = (contents) => {
let filteredContents = contents let filteredContents = contents

View File

@ -0,0 +1,11 @@
#import "../fragments/contentBlockInterfaceParts.gql"
#import "../fragments/contentBlockParts.gql"
mutation DuplicateContentBlock($input: DuplicateContentBlockInput!) {
duplicateContentBlock(input: $input) {
contentBlock {
...ContentBlockInterfaceParts
...ContentBlockParts
}
}
}

View File

@ -14,7 +14,7 @@
import EDIT_CONTENT_BLOCK_MUTATION from '@/graphql/gql/mutations/editContentBlock.gql'; import EDIT_CONTENT_BLOCK_MUTATION from '@/graphql/gql/mutations/editContentBlock.gql';
import MODULE_DETAILS_QUERY from '@/graphql/gql/queries/modules/moduleDetailsQuery.gql'; import MODULE_DETAILS_QUERY from '@/graphql/gql/queries/modules/moduleDetailsQuery.gql';
import {setUserBlockType} from '@/helpers/content-block'; import {setUserBlockType} from '@/helpers/content-block';
import {cleanUpContents} from '@/components/content-block-form/helpers'; import {cleanUpContents, simplifyContents} from '@/components/content-block-form/helpers';
// import ContentBlockForm from '@/components/content-block-form/ContentBlockForm'; // import ContentBlockForm from '@/components/content-block-form/ContentBlockForm';
@ -62,10 +62,11 @@
methods: { methods: {
save({title, contents, isAssignment, id}) { save({title, contents, isAssignment, id}) {
let cleanedContents = cleanUpContents(contents); const cleanedContents = cleanUpContents(contents);
const simplifiedContents = simplifyContents(cleanedContents);
const contentBlock = { const contentBlock = {
title: title, title: title,
contents: cleanedContents, contents: simplifiedContents,
type: setUserBlockType(isAssignment), type: setUserBlockType(isAssignment),
}; };
const { slug } = this.$route.params; const { slug } = this.$route.params;

View File

@ -175,7 +175,7 @@ class ContentBlockFactory(BasePageFactory):
if stream_field_name in kwargs: if stream_field_name in kwargs:
""" """
stream_field_name is most likely 'contents' stream_field_name is most likely 'contents'
this means: if there is a property named contents, us the defined ones in this block. this means: if there is a property named contents, use the defined ones in this block.
otherwise, go into the other block and randomize the contents otherwise, go into the other block and randomize the contents
""" """
for idx, resource in enumerate(kwargs[stream_field_name]): for idx, resource in enumerate(kwargs[stream_field_name]):

27
server/books/managers.py Normal file
View File

@ -0,0 +1,27 @@
from wagtail.core.models import PageManager
from typing import TYPE_CHECKING
from core.logger import get_logger
if TYPE_CHECKING:
from books.models import ContentBlock
logger = get_logger(__name__)
class ContentBlockManager(PageManager):
def duplicate(self, content_block: "ContentBlock", user):
try:
new_content_block = self.model(
user_created=True,
owner=user,
contents=content_block.contents,
title=f'{content_block.title} (Kopie)',
type=content_block.type,
)
content_block.add_sibling(instance=new_content_block, pos='left')
revision = new_content_block.save_revision()
revision.publish()
new_content_block.save()
return new_content_block
except Exception as e:
logger.warn(e)

View File

@ -6,6 +6,7 @@ from wagtail.core.blocks import StreamBlock
from wagtail.core.fields import StreamField from wagtail.core.fields import StreamField
from wagtail.images.blocks import ImageChooserBlock from wagtail.images.blocks import ImageChooserBlock
from books.managers import ContentBlockManager
from core.wagtail_utils import get_default_settings from core.wagtail_utils import get_default_settings
from books.blocks import CMSDocumentBlock, SolutionBlock, TextBlock, BasicKnowledgeBlock, LinkBlock, VideoBlock, \ from books.blocks import CMSDocumentBlock, SolutionBlock, TextBlock, BasicKnowledgeBlock, LinkBlock, VideoBlock, \
DocumentBlock, \ DocumentBlock, \
@ -90,6 +91,8 @@ class ContentBlock(StrictHierarchyPage):
parent_page_types = ['books.Chapter'] parent_page_types = ['books.Chapter']
subpage_types = [] subpage_types = []
objects = ContentBlockManager()
@property @property
def module(self): def module(self):
return self.get_parent().get_parent().specific return self.get_parent().get_parent().specific

View File

@ -13,6 +13,7 @@ class InputTypes(graphene.Enum):
document_block = 'document_block' document_block = 'document_block'
content_list_item = 'content_list_item' content_list_item = 'content_list_item'
subtitle = 'subtitle' subtitle = 'subtitle'
readonly = 'readonly'
class ContentElementValueInput(InputObjectType): class ContentElementValueInput(InputObjectType):

View File

@ -1,5 +1,6 @@
from books.schema.mutations.chapter import UpdateChapterVisibility from books.schema.mutations.chapter import UpdateChapterVisibility
from books.schema.mutations.contentblock import MutateContentBlock, AddContentBlock, DeleteContentBlock from books.schema.mutations.contentblock import DuplicateContentBlock, MutateContentBlock, AddContentBlock, \
DeleteContentBlock
from books.schema.mutations.module import UpdateSolutionVisibility, UpdateLastModule, SyncModuleVisibility from books.schema.mutations.module import UpdateSolutionVisibility, UpdateLastModule, SyncModuleVisibility
from books.schema.mutations.snapshot import CreateSnapshot, ApplySnapshot, ShareSnapshot, UpdateSnapshot, DeleteSnapshot from books.schema.mutations.snapshot import CreateSnapshot, ApplySnapshot, ShareSnapshot, UpdateSnapshot, DeleteSnapshot
from books.schema.mutations.topic import UpdateLastTopic from books.schema.mutations.topic import UpdateLastTopic
@ -9,6 +10,7 @@ class BookMutations(object):
mutate_content_block = MutateContentBlock.Field() mutate_content_block = MutateContentBlock.Field()
add_content_block = AddContentBlock.Field() add_content_block = AddContentBlock.Field()
delete_content_block = DeleteContentBlock.Field() delete_content_block = DeleteContentBlock.Field()
duplicate_content_block = DuplicateContentBlock.Field()
update_solution_visibility = UpdateSolutionVisibility.Field() update_solution_visibility = UpdateSolutionVisibility.Field()
update_last_module = UpdateLastModule.Field() update_last_module = UpdateLastModule.Field()
update_last_topic = UpdateLastTopic.Field() update_last_topic = UpdateLastTopic.Field()

View File

@ -48,7 +48,7 @@ class MutateContentBlock(relay.ClientIDMutation):
content_block.title = title content_block.title = title
if contents is not None: if contents is not None:
content_block.contents = json.dumps([handle_content_block(c, info.context, module) for c in contents]) content_block.contents = json.dumps([handle_content_block(c, info.context, module, previous_contents=content_block.contents) for c in contents if c is not None])
content_block.save() content_block.save()
@ -143,3 +143,22 @@ class DeleteContentBlock(relay.ClientIDMutation):
return cls(success=True) return cls(success=True)
except ContentBlock.DoesNotExist: except ContentBlock.DoesNotExist:
return cls(success=False, errors='Content block not found') return cls(success=False, errors='Content block not found')
class DuplicateContentBlock(relay.ClientIDMutation):
class Input:
id = graphene.ID(required=True)
content_block = graphene.Field(ContentBlockNode)
@classmethod
def mutate_and_get_payload(cls, root, info, **kwargs):
id = from_global_id(kwargs.get('id'))[1]
user = info.context.user
try:
content_block = ContentBlock.objects.get(pk=id)
new_content_block = ContentBlock.objects.duplicate(content_block=content_block, user=user)
return cls(content_block=new_content_block)
except ContentBlock.DoesNotExist:
return cls(content_block=None)

View File

@ -41,10 +41,11 @@ ALLOWED_BLOCKS = (
'document_block', 'document_block',
'content_list_item', 'content_list_item',
'subtitle', 'subtitle',
'readonly'
) )
def handle_content_block(content, context=None, module=None, allowed_blocks=ALLOWED_BLOCKS): def handle_content_block(content, context=None, module=None, allowed_blocks=ALLOWED_BLOCKS, previous_contents=None):
# todo: add all the content blocks # todo: add all the content blocks
# todo: sanitize user inputs! # todo: sanitize user inputs!
if content['type'] not in allowed_blocks: if content['type'] not in allowed_blocks:
@ -64,9 +65,10 @@ def handle_content_block(content, context=None, module=None, allowed_blocks=ALLO
value = content['value'] value = content['value']
if value.get('id') is not None: if value.get('id') is not None:
assignment = get_object(Assignment, value.get('id')) assignment = get_object(Assignment, value.get('id'))
assignment.title = value.get('title') if assignment.user_created and assignment.owner == context.user:
assignment.assignment = value.get('assignment') assignment.title = value.get('title')
assignment.save() assignment.assignment = value.get('assignment')
assignment.save()
else: else:
assignment = Assignment.objects.create( assignment = Assignment.objects.create(
title=value.get('title'), title=value.get('title'),
@ -119,7 +121,12 @@ def handle_content_block(content, context=None, module=None, allowed_blocks=ALLO
'type': 'content_list_item', 'type': 'content_list_item',
'value': [handle_content_block(c, context, module) for c in content['contents']] 'value': [handle_content_block(c, context, module) for c in content['contents']]
} }
elif content['type'] == 'readonly' and previous_contents is not None:
# get first item that matches the id
# users can re-order readonly items, but we won't let them change them otherwise, so we just take the
# item from before and ignore anything else
previous_content = next((c for c in previous_contents.raw_data if c['id'] == content['id']), None)
return previous_content
return None return None

View File

@ -0,0 +1,186 @@
from graphql_relay import to_global_id
from wagtail.core.fields import StreamField
from wagtail.tests.utils.form_data import streamfield, nested_form_data, rich_text
from books.factories import ContentBlockFactory, ModuleFactory, ChapterFactory
from books.models import ContentBlock
from core.tests.base_test import SkillboxTestCase
DUPLICATE_CONTENT_BLOCK_MUTATION = """
mutation DuplicateContentBlockMutation($input: DuplicateContentBlockInput!) {
duplicateContentBlock(input: $input) {
contentBlock {
id
}
}
}
"""
CONTENT_BLOCK_QUERY = """
query ContentBlockQuery($slug: String!) {
module(slug: $slug) {
chapters {
id
contentBlocks {
id
title
type
}
}
}
}
"""
class DuplicateContentBlockTestCase(SkillboxTestCase):
def setUp(self) -> None:
self.createDefault()
#
self.slug = 'module'
self.module = ModuleFactory(slug=self.slug)
self.chapter = ChapterFactory(parent=self.module)
self.content_block = ContentBlock(
type=ContentBlock.NORMAL,
title='Title',
)
self.chapter.add_child(instance=self.content_block)
def test_duplicate_content_block(self):
result = self.get_client().execute(CONTENT_BLOCK_QUERY, variables={
'slug': self.slug
})
self.assertIsNone(result.errors)
module = result.data.get('module')
chapter = module.get('chapters')[0]
content_blocks = chapter.get('contentBlocks')
self.assertEqual(len(content_blocks), 1)
self.assertEqual(ContentBlock.objects.count(), 1)
result = self.get_client().execute(DUPLICATE_CONTENT_BLOCK_MUTATION, variables={
'input': {
"id": to_global_id('ContentBlockNode', self.content_block.id)
}
})
self.assertIsNone(result.errors)
duplicate_content_block = result.data.get('duplicateContentBlock')
content_block = duplicate_content_block.get('contentBlock')
# self.assertEqual(content_block['title'], 'Title')
self.assertIsNotNone(content_block['id'])
result = self.get_client().execute(CONTENT_BLOCK_QUERY, variables={
'slug': self.slug
})
self.assertIsNone(result.errors)
module = result.data.get('module')
chapter = module.get('chapters')[0]
content_blocks = chapter.get('contentBlocks')
self.assertEqual(ContentBlock.objects.count(), 2)
self.assertEqual(len(content_blocks), 2)
self.assertTrue('Kopie' in content_blocks[0].get('title'))
self.assertTrue('Kopie' not in content_blocks[1].get('title'))
# def test_duplicate_non_editable_contents(self):
# # todo: find out how to create contents for tests, then re-enable
# # contents__0__text_block__text
# nested_form_data({
# 'content': streamfield([
# nested_form_data({
# 'text_block': [
# ('text', rich_text('Asdf'))
# ]
# })
# ])
# })
#
# contents = [
# nested_form_data({
# 'text_block': streamfield([
# ('text', 'Asdf')
# ])
# }),
# # {
# # "type": "text_block",
# # "value": {
# # "text": "Asdf"
# # },
# # },
# # {
# # "type": "assignment",
# # "value": {
# # "title": "Ein Auftragstitel",
# # "assignment": "Ein Auftrag",
# # },
# # },
# # {
# # "type": "image_block",
# # "value": {
# # "path": "/media/original_images/dummy_pZUH02q.jpg"
# # },
# # },
# # {
# # "type": "image_url_block",
# # "value": {
# # "title": "Asdf",
# # "url": "http://localhost:8000/media/images/dummy_pZUH02q.max-165x165.jpg"
# # },
# # },
# # {
# # "type": "link_block",
# # "value": {
# # "text": "Asdf",
# # "url": "https://iterativ.ch"
# # },
# # },
# # {
# # "type": "solution",
# # "value": {
# # "text": "Asdf",
# # },
# # },
# # {
# # "type": "video_block",
# # "value": {
# # "url": "https://www.youtube.com/watch?v=QxQBWR7sntI"
# # },
# # },
# # {
# # "type": "document_block",
# # "value": {
# # "url": "http://localhost:8000/media/images/dummy_pZUH02q.max-165x165.jpg"
# # },
# # },
# # {
# # "type": "infogram_block",
# # "value": {
# # "id": "4405271e-dbfb-407e-ac19-0a238cde393f",
# # "title": "Gerät Internetnutzung Jungen"
# # },
# # },
# # {
# # "type": "thinglink_block",
# # "value": {
# # "id": "1314204266449076227"
# # },
# # },
# # {
# # "type": "subtitle",
# # "value": {
# # "text": "Subtitle"
# # },
# # },
# # {
# # "type": "instruction",
# # "value": {
# # "url": "http://localhost:8000/media/images/dummy_pZUH02q.max-165x165.jpg",
# # "text": "Instruction",
# # },
# # },
# # {
# # "type": "module_room_slug",
# # "value": {
# # "title": "Raum",
# # },
# # },
# ]
# self.content_block.contents = contents
# self.assertTrue(False)

View File

@ -478,6 +478,16 @@ type DjangoDebugSQL {
encoding: String encoding: String
} }
input DuplicateContentBlockInput {
id: ID!
clientMutationId: String
}
type DuplicateContentBlockPayload {
contentBlock: ContentBlockNode
clientMutationId: String
}
type DuplicateName { type DuplicateName {
reason: String reason: String
} }
@ -694,6 +704,7 @@ type Mutation {
mutateContentBlock(input: MutateContentBlockInput!): MutateContentBlockPayload mutateContentBlock(input: MutateContentBlockInput!): MutateContentBlockPayload
addContentBlock(input: AddContentBlockInput!): AddContentBlockPayload addContentBlock(input: AddContentBlockInput!): AddContentBlockPayload
deleteContentBlock(input: DeleteContentBlockInput!): DeleteContentBlockPayload deleteContentBlock(input: DeleteContentBlockInput!): DeleteContentBlockPayload
duplicateContentBlock(input: DuplicateContentBlockInput!): DuplicateContentBlockPayload
updateSolutionVisibility(input: UpdateSolutionVisibilityInput!): UpdateSolutionVisibilityPayload updateSolutionVisibility(input: UpdateSolutionVisibilityInput!): UpdateSolutionVisibilityPayload
updateLastModule(input: UpdateLastModuleInput!): UpdateLastModulePayload updateLastModule(input: UpdateLastModuleInput!): UpdateLastModulePayload
updateLastTopic(input: UpdateLastTopicInput!): UpdateLastTopicPayload updateLastTopic(input: UpdateLastTopicInput!): UpdateLastTopicPayload