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.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
cy.log('Deleting Content Block');
cy.getByDataCy('delete-content-block-link').click();

View File

@ -11,14 +11,28 @@
>
<div
class="block-actions"
v-if="canEditContentBlock && editMode"
v-if="canEditModule && !isInstrumentBlock"
>
<user-widget
v-bind="me"
class="block-actions__user-widget content-block__user-widget"
v-if="isMine"
/>
<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
data-cy="delete-content-block-link"
text="Löschen"
@ -26,7 +40,10 @@
/>
</li>
<li class="popover-links__link">
<li
class="popover-links__link"
v-if="isMine"
>
<popover-link
text="Bearbeiten"
@link-action="editContentBlock(contentBlock)"
@ -83,13 +100,14 @@
import CHAPTER_QUERY from '@/graphql/gql/queries/chapterQuery.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 {hidden} from '@/helpers/visibility';
import {CONTENT_TYPE} from '@/consts/types';
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 {instrumentCategory} from '@/helpers/instrumentType';
@ -163,7 +181,10 @@
return {};
},
canEditContentBlock() {
return this.contentBlock.mine && !this.contentBlock.indent;
return this.isMine && !this.contentBlock.indent;
},
isMine() {
return this.contentBlock.mine;
},
contentBlocksWithContentLists() {
/*
@ -218,6 +239,36 @@
},
},
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) {
const route = {
name: EDIT_CONTENT_BLOCK_PAGE,

View File

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

View File

@ -9,6 +9,7 @@
@remove="$emit('remove', false)"
/>
<!-- Content Forms -->
<content-form-section
:title="title"
@ -55,6 +56,16 @@
const AssignmentForm = () => import(/* webpackChunkName: "content-forms" */'@/components/content-forms/AssignmentForm');
const TextForm = () => import(/* webpackChunkName: "content-forms" */'@/components/content-forms/TipTap.vue');
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';
@ -91,6 +102,15 @@
AssignmentForm,
TextForm,
SubtitleForm,
SurveyBlock,
Solution,
ImageBlock,
Instruction,
ModuleRoomSlug,
CmsDocumentBlock,
InfogramBlock,
ThinglinkBlock,
Assignment
},
computed: {
@ -153,7 +173,7 @@
};
case 'assignment':
return {
component: 'assignment-form',
component: element.id ? 'assignment' : 'assignment-form', // prevent editing of existing assignments
title: 'Aufgabe & Ergebnis',
icon: 'speech-bubble-icon',
};
@ -163,6 +183,46 @@
title: 'Dokument',
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 {
component: CHOOSER,

View File

@ -1,5 +1,28 @@
export const CHOOSER = 'content-block-element-chooser-widget';
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) => {
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 MODULE_DETAILS_QUERY from '@/graphql/gql/queries/modules/moduleDetailsQuery.gql';
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';
@ -62,10 +62,11 @@
methods: {
save({title, contents, isAssignment, id}) {
let cleanedContents = cleanUpContents(contents);
const cleanedContents = cleanUpContents(contents);
const simplifiedContents = simplifyContents(cleanedContents);
const contentBlock = {
title: title,
contents: cleanedContents,
contents: simplifiedContents,
type: setUserBlockType(isAssignment),
};
const { slug } = this.$route.params;

View File

@ -175,7 +175,7 @@ class ContentBlockFactory(BasePageFactory):
if stream_field_name in kwargs:
"""
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
"""
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.images.blocks import ImageChooserBlock
from books.managers import ContentBlockManager
from core.wagtail_utils import get_default_settings
from books.blocks import CMSDocumentBlock, SolutionBlock, TextBlock, BasicKnowledgeBlock, LinkBlock, VideoBlock, \
DocumentBlock, \
@ -90,6 +91,8 @@ class ContentBlock(StrictHierarchyPage):
parent_page_types = ['books.Chapter']
subpage_types = []
objects = ContentBlockManager()
@property
def module(self):
return self.get_parent().get_parent().specific

View File

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

View File

@ -1,5 +1,6 @@
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.snapshot import CreateSnapshot, ApplySnapshot, ShareSnapshot, UpdateSnapshot, DeleteSnapshot
from books.schema.mutations.topic import UpdateLastTopic
@ -9,6 +10,7 @@ class BookMutations(object):
mutate_content_block = MutateContentBlock.Field()
add_content_block = AddContentBlock.Field()
delete_content_block = DeleteContentBlock.Field()
duplicate_content_block = DuplicateContentBlock.Field()
update_solution_visibility = UpdateSolutionVisibility.Field()
update_last_module = UpdateLastModule.Field()
update_last_topic = UpdateLastTopic.Field()

View File

@ -48,7 +48,7 @@ class MutateContentBlock(relay.ClientIDMutation):
content_block.title = title
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()
@ -143,3 +143,22 @@ class DeleteContentBlock(relay.ClientIDMutation):
return cls(success=True)
except ContentBlock.DoesNotExist:
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',
'content_list_item',
'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: sanitize user inputs!
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']
if value.get('id') is not None:
assignment = get_object(Assignment, value.get('id'))
assignment.title = value.get('title')
assignment.assignment = value.get('assignment')
assignment.save()
if assignment.user_created and assignment.owner == context.user:
assignment.title = value.get('title')
assignment.assignment = value.get('assignment')
assignment.save()
else:
assignment = Assignment.objects.create(
title=value.get('title'),
@ -119,7 +121,12 @@ def handle_content_block(content, context=None, module=None, allowed_blocks=ALLO
'type': 'content_list_item',
'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

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