Merged in feature/rooms-updated-edit-fields-MS-486-MS-487 (pull request #111)

Feature/rooms updated edit fields MS-486 MS 487

Approved-by: Lorenz Padberg
This commit is contained in:
Ramon Wenger 2022-07-11 13:26:51 +00:00
commit ff7e5ad1f6
37 changed files with 760 additions and 353 deletions

View File

@ -3,11 +3,22 @@ import {getMinimalMe} from '../../../support/helpers';
describe('Article page', () => { describe('Article page', () => {
const slug = 'this-article-has-a-slug'; const slug = 'this-article-has-a-slug';
const roomEntry = { const roomEntry = {
slug, slug,
id: 'room-entry-id', id: 'room-entry-id',
title: 'Some Room Entry, yay!', title: 'Some Room Entry, yay!',
comments: [], comments: [],
}; contents: [{
type: 'text_block',
value: {
text: 'Ein Text',
},
}, {
type: 'subtitle',
value: {
text: 'Ein Untertitel'
}
}],
};
const operations = { const operations = {
MeQuery: getMinimalMe({}), MeQuery: getMinimalMe({}),
@ -23,18 +34,28 @@ describe('Article page', () => {
roomEntry: roomEntry, roomEntry: roomEntry,
owner: { owner: {
firstName: 'Matt', firstName: 'Matt',
lastName: 'Damon' lastName: 'Damon',
} },
} },
} },
}; };
} },
}; };
beforeEach(() => { beforeEach(() => {
cy.setup(); cy.setup();
}); });
it('shows the article with contents', () => {
cy.mockGraphqlOps({
operations,
});
cy.visit(`/article/${slug}`);
cy.getByDataCy('text-block').should('contain.text', 'Ein Text');
cy.getByDataCy('subtitle-block').should('contain.text', 'Ein Untertitel');
});
it('goes to article and leaves a comment', () => { it('goes to article and leaves a comment', () => {
cy.mockGraphqlOps({ cy.mockGraphqlOps({
operations, operations,

View File

@ -1,6 +1,6 @@
import {getMinimalMe} from '../../../support/helpers'; import {getMinimalMe} from '../../../support/helpers';
describe('The Room Page', () => { describe('The Room Page (Teacher)', () => {
const MeQuery = getMinimalMe(); const MeQuery = getMinimalMe();
const selectedClass = MeQuery.me.selectedClass; const selectedClass = MeQuery.me.selectedClass;
const entryText = 'something should be here'; const entryText = 'something should be here';
@ -61,32 +61,17 @@ describe('The Room Page', () => {
}); });
cy.visit(`/room/${slug}`); cy.visit(`/room/${slug}`);
cy.get('[data-cy=add-room-entry-button]').click(); cy.getByDataCy('add-room-entry-button').click();
cy.get('.add-content-element:first-of-type').click(); cy.getByDataCy('add-content-link').first().click();
cy.get('[data-cy=choose-text-widget]').click(); cy.getByDataCy('choose-text-widget').click();
cy.get('[data-cy=modal-title-input] > .modal-input__inputfield').type(entryTitle); cy.getByDataCy('input-with-label-input').type(entryTitle);
cy.get('[data-cy=text-form-input]').type(entryText); cy.get('.tip-tap__editor').type(entryText);
cy.get('[data-cy=modal-save-button]').click(); cy.getByDataCy('save-button').click();
cy.get('.room-entry__content:first').should('contain', entryText).should('contain', 'Rachel Green'); cy.get('.room-entry__content:first').should('contain', entryText).should('contain', 'Rachel Green');
}); });
it('room actions should not exist for student', () => {
const operations = {
MeQuery: getMinimalMe({isTeacher: false}),
RoomEntriesQuery,
};
cy.mockGraphqlOps({
operations,
});
cy.visit(`/room/${slug}`);
cy.getByDataCy('room-title').should('exist');
cy.getByDataCy('room-actions').should('not.exist');
});
// todo: re-enable once cypress can do it correctly // todo: re-enable once cypress can do it correctly
it.skip('changes visibility of a room', () => { it.skip('changes visibility of a room', () => {
const MeQuery = getMinimalMe({ const MeQuery = getMinimalMe({
@ -149,6 +134,7 @@ describe('The Room Page', () => {
}; };
const otherRoom = { const otherRoom = {
id: btoa('RoomNode:otherRoom'), id: btoa('RoomNode:otherRoom'),
slug: 'other-slug',
schoolClass, schoolClass,
}; };
let rooms = [roomToDelete, otherRoom]; let rooms = [roomToDelete, otherRoom];
@ -156,7 +142,7 @@ describe('The Room Page', () => {
MeQuery, MeQuery,
RoomsQuery() { RoomsQuery() {
return { return {
rooms rooms,
}; };
}, },
RoomEntriesQuery: { RoomEntriesQuery: {
@ -176,47 +162,126 @@ describe('The Room Page', () => {
cy.getByDataCy('room-widget').should('have.length', 2); cy.getByDataCy('room-widget').should('have.length', 2);
cy.getByDataCy('room-widget').first().click(); cy.getByDataCy('room-widget').first().click();
cy.getByDataCy('toggle-more-actions-menu').click(); cy.getByDataCy('toggle-more-actions-menu').click();
cy.getByDataCy('delete-room').click(); cy.getByDataCy('delete-room').within(() => {
cy.get('a').click();
});
cy.url().should('include', 'rooms'); cy.url().should('include', 'rooms');
cy.getByDataCy('room-widget').should('have.length', 1); cy.getByDataCy('room-widget').should('have.length', 1);
}); });
it('edits own room entry', () => { it('changes class while on room page', () => {
const MeQuery = getMinimalMe({isTeacher: false});
const {me} = MeQuery; const {me} = MeQuery;
const id = atob(me.id).split(':')[1]; const otherClass = {
const authorId = btoa(`PublicUserNode:${id}`); id: btoa('SchoolClassNode:34'),
const room = { name: 'Other Class',
id: 'some-room', readOnly: false,
roomEntries: {
edges: [
{
node: {
id: '',
slug: '',
contents: [],
author: {
...me,
id: authorId,
},
},
},
],
},
}; };
let selectedClass = me.selectedClass;
const operations = { const operations = {
MeQuery: MeQuery, MeQuery: () => {
RoomEntriesQuery: { return {
room, me: {
...me,
schoolClasses: [...me.schoolClasses, otherClass],
selectedClass,
},
};
},
RoomEntriesQuery,
UpdateSettings() {
selectedClass = otherClass;
return {
updateSettings: {
success: true,
},
};
},
ModuleDetailsQuery: {},
MySchoolClassQuery: () => {
return {
me: {
selectedClass,
},
};
},
RoomsQuery: {
rooms: [],
}, },
RoomEntryQuery: {},
}; };
cy.mockGraphqlOps({ cy.mockGraphqlOps({
operations, operations,
}); });
cy.visit(`/room/${slug}`); cy.visit(`/room/${slug}`);
cy.getByDataCy('room-entry-actions').click(); cy.getByDataCy('room-title').should('contain', 'A Room');
cy.getByDataCy('edit-room-entry').click(); cy.selectClass('Other Class');
cy.url().should('include', 'rooms');
cy.getByDataCy('current-class-name').should('contain', 'Other Class');
});
});
describe('The Room Page (student)', () => {
const slug = 'ein-historisches-festival';
const MeQuery = getMinimalMe({isTeacher: false});
const {me} = MeQuery;
const id = atob(me.id).split(':')[1];
const authorId = btoa(`PublicUserNode:${id}`);
const entrySlug = 'entry-slug';
const {selectedClass} = me;
const roomEntry = {
id: 'entry-id',
slug: entrySlug,
title: 'My Entry',
contents: [
{
type: 'text_block',
value: {
text: 'some text',
},
},
],
comments: [{}, {}],
author: {
...me,
id: authorId,
firstName: 'Hans',
lastName: 'Was Heiri',
avatarUrl: '',
},
};
const room = {
id,
slug,
schoolClass: selectedClass,
restricted: false,
roomEntries: {
edges: [{
node: roomEntry,
}],
},
};
const RoomEntriesQuery = {
room,
};
beforeEach(() => {
cy.setup();
});
it('room actions should not exist for student', () => {
const operations = {
MeQuery: getMinimalMe({isTeacher: false}),
RoomEntriesQuery,
};
cy.mockGraphqlOps({
operations,
});
cy.visit(`/room/${slug}`);
cy.getByDataCy('room-title').should('exist');
cy.getByDataCy('room-actions').should('not.exist');
}); });
it('creates a room entry', () => { it('creates a room entry', () => {
@ -240,56 +305,83 @@ describe('The Room Page', () => {
cy.visit(`/room/${slug}`); cy.visit(`/room/${slug}`);
cy.getByDataCy('add-room-entry-button').click(); cy.getByDataCy('add-room-entry-button').click();
cy.getByDataCy('add-room-entry-modal').should('exist');
cy.getByDataCy('content-form-section-title').should('have.text', 'Titel (Pflichtfeld)');
}); });
it.only('changes class while on room page', () => { it('edits own room entry', () => {
const {me} = MeQuery; const room = {
const otherClass = { id: 'some-room',
id: btoa('SchoolClassNode:34'), slug,
name: 'Other Class', // schoolClass: me.selectedClass,
readOnly: false roomEntries: {
edges: [
{
node: roomEntry,
},
],
},
}; };
let selectedClass = me.selectedClass;
const operations = { const operations = {
MeQuery: () => { MeQuery: MeQuery,
return { RoomEntriesQuery: {
me: { room,
...me,
schoolClasses: [...me.schoolClasses, otherClass],
selectedClass
},
};
}, },
RoomEntryQuery: {
roomEntry,
},
};
cy.mockGraphqlOps({
operations,
});
cy.visit(`/room/${slug}`);
cy.getByDataCy('room-entry-actions').click();
cy.getByDataCy('edit-room-entry').click();
cy.location('pathname').should('include', entrySlug);
});
it('deletes room entry', () => {
const DeleteRoomEntry = {
deleteRoomEntry: {
success: true,
errors: null,
roomSlug: slug,
},
};
const operations = {
MeQuery,
RoomEntriesQuery, RoomEntriesQuery,
UpdateSettings() { DeleteRoomEntry,
selectedClass = otherClass;
return {
updateSettings: {
success: true
}
};
},
ModuleDetailsQuery: {},
MySchoolClassQuery: () => {
return {
me: {
selectedClass
}
};
},
RoomsQuery: {
rooms: []
}
}; };
cy.mockGraphqlOps({ cy.mockGraphqlOps({
operations, operations,
}); });
cy.visit(`/room/${slug}`); cy.visit(`/room/${slug}`);
cy.getByDataCy('room-title').should('contain', 'A Room'); cy.getByDataCy('room-entry').should('have.length', 1);
cy.selectClass('Other Class'); cy.getByDataCy('room-entry-actions').click();
cy.url().should('include', 'rooms'); cy.getByDataCy('delete-room-entry').click();
cy.getByDataCy('current-class-name').should('contain', 'Other Class'); cy.getByDataCy('delete-room-entry').should('not.exist');
cy.getByDataCy('modal-save-button').click();
cy.getByDataCy('room-entry').should('have.length', 0);
});
it('shows room entries with comment count', () => {
const operations = {
MeQuery,
RoomEntriesQuery,
};
cy.mockGraphqlOps({
operations,
});
cy.visit(`/room/${slug}`);
cy.getByDataCy('room-entry').should('have.length', 1).within(() => {
cy.getByDataCy('entry-count').should('contain.text', '2');
});
}); });
}); });

View File

@ -27,7 +27,6 @@
const NewContentBlockWizard = () => import(/* webpackChunkName: "content-forms" */'@/components/content-block-form/NewContentBlockWizard'); const NewContentBlockWizard = () => import(/* webpackChunkName: "content-forms" */'@/components/content-block-form/NewContentBlockWizard');
const EditContentBlockWizard = () => import(/* webpackChunkName: "content-forms" */'@/components/content-block-form/EditContentBlockWizard'); const EditContentBlockWizard = () => import(/* webpackChunkName: "content-forms" */'@/components/content-block-form/EditContentBlockWizard');
const NewRoomEntryWizard = () => import(/* webpackChunkName: "content-forms" */'@/components/rooms/room-entries/NewRoomEntryWizard');
const EditRoomEntryWizard = () => import(/* webpackChunkName: "content-forms" */'@/components/rooms/room-entries/EditRoomEntryWizard'); const EditRoomEntryWizard = () => import(/* webpackChunkName: "content-forms" */'@/components/rooms/room-entries/EditRoomEntryWizard');
const NewProjectEntryWizard = () => import(/* webpackChunkName: "content-forms" */'@/components/portfolio/NewProjectEntryWizard'); const NewProjectEntryWizard = () => import(/* webpackChunkName: "content-forms" */'@/components/portfolio/NewProjectEntryWizard');
const EditProjectEntryWizard = () => import(/* webpackChunkName: "content-forms" */'@/components/portfolio/EditProjectEntryWizard'); const EditProjectEntryWizard = () => import(/* webpackChunkName: "content-forms" */'@/components/portfolio/EditProjectEntryWizard');
@ -58,7 +57,6 @@
SplitLayout, SplitLayout,
NewContentBlockWizard, NewContentBlockWizard,
EditContentBlockWizard, EditContentBlockWizard,
NewRoomEntryWizard,
EditRoomEntryWizard, EditRoomEntryWizard,
NewProjectEntryWizard, NewProjectEntryWizard,
EditProjectEntryWizard, EditProjectEntryWizard,

View File

@ -14,11 +14,15 @@
:checked="localContentBlock.isAssignment" :checked="localContentBlock.isAssignment"
class="content-block-form__task-toggle" class="content-block-form__task-toggle"
label="Inhaltsblock als Auftrag formatieren" label="Inhaltsblock als Auftrag formatieren"
v-if="hasDefaultFeatures"
@input="localContentBlock.isAssignment=$event" @input="localContentBlock.isAssignment=$event"
/> />
<!-- Form for title of content block --> <!-- Form for title of content block -->
<content-form-section title="Titel (Pflichtfeld)"> <content-form-section
data-cy="content-form-title-section"
title="Titel (Pflichtfeld)"
>
<input-with-label <input-with-label
:value="localContentBlock.title" :value="localContentBlock.title"
data-cy="content-block-title" data-cy="content-block-title"
@ -139,8 +143,12 @@
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';
import {ContentBlock, numberOrUndefined} from "@/@types"; import {ContentBlock, numberOrUndefined} from "@/@types";
import {DEFAULT_FEATURE_SET} from "@/consts/features.consts";
// TODO: refactor this file, it's huuuuuge! // TODO: refactor this file, it's huuuuuge!
interface ContentBlockFormData {
localContentBlock: any;
}
export default Vue.extend({ export default Vue.extend({
props: { props: {
@ -152,6 +160,15 @@
type: Object as PropType<ContentBlock>, type: Object as PropType<ContentBlock>,
required: true, required: true,
}, },
features: {
type: String,
default: DEFAULT_FEATURE_SET
}
},
provide(): object {
return {
features: this.features
};
}, },
components: { components: {
ContentElementActions, ContentElementActions,
@ -161,7 +178,7 @@
ContentFormSection, ContentFormSection,
Toggle, Toggle,
}, },
data() { data(): ContentBlockFormData {
return { return {
localContentBlock: Object.assign({}, { localContentBlock: Object.assign({}, {
title: this.contentBlock.title, title: this.contentBlock.title,
@ -176,6 +193,9 @@
isValid(): boolean { isValid(): boolean {
return this.localContentBlock.title > ''; return this.localContentBlock.title > '';
}, },
hasDefaultFeatures(): boolean {
return this.features === DEFAULT_FEATURE_SET;
}
}, },
methods: { methods: {
update(index: number, element: any, parent?: number) { update(index: number, element: any, parent?: number) {

View File

@ -4,7 +4,10 @@
<component <component
class="content-form-section__icon" class="content-form-section__icon"
:is="icon" :is="icon"
/> <span class="content-form-section__title">{{ title }}</span> /> <span
class="content-form-section__title"
data-cy="content-form-section-title"
>{{ title }}</span>
</h2> </h2>
<content-element-actions <content-element-actions

View File

@ -2,6 +2,7 @@
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<h5 <h5
class="subtitle" class="subtitle"
data-cy="subtitle-block"
v-html="sanitizedText" v-html="sanitizedText"
/> />
</template> </template>

View File

@ -2,6 +2,7 @@
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<div <div
class="text-block" class="text-block"
data-cy="text-block"
v-html="sanitizedText" v-html="sanitizedText"
/> />
</template> </template>

View File

@ -0,0 +1,76 @@
<template>
<div
:class="['chooser-element', subclass]"
:data-cy="cy"
@click="$emit('select')"
>
<component
class="chooser-element__icon"
:is="icon"
/>
<div class="chooser-element__title">
{{ title }}
</div>
</div>
</template>
<script>
import formElementIcons from '@/components/ui/form-element-icons';
export default {
props: {
type: {
type: String,
default: '',
},
icon: {
type: String,
default() {
return `${this.type}-icon`;
},
},
title: {
type: String,
default() {
return this.type.replace(/^\w/, c => c.toUpperCase());
},
},
},
components: {
...formElementIcons,
},
data() {
return {
subclass: `chooser-element--${this.type}`,
cy: `choose-${this.type}-widget`,
};
},
};
</script>
<style scoped lang="scss">
@import '~styles/helpers';
.chooser-element {
cursor: pointer;
border: 1px solid $color-silver;
border-radius: 4px;
height: 105px;
width: 105px;
box-sizing: border-box;
display: grid;
grid-template-rows: 1fr 45px;
justify-content: center;
justify-items: center;
align-items: center;
&__icon {
width: 40px;
height: 40px;
align-self: end;
}
}
</style>

View File

@ -13,7 +13,7 @@
Neuer Inhalt Neuer Inhalt
</h3> </h3>
<template <template
v-if="includeListOption" v-if="includeListOption && hasDefaultFeatures"
> >
<checkbox <checkbox
class="content-block-element-chooser-widget__list-toggle" class="content-block-element-chooser-widget__list-toggle"
@ -28,77 +28,14 @@
:class="{'content-block-element-chooser-widget--no-assignment': hideAssignment}" :class="{'content-block-element-chooser-widget--no-assignment': hideAssignment}"
class="content-block-element-chooser-widget" class="content-block-element-chooser-widget"
> >
<div <chooser-element
class="content-block-element-chooser-widget__link content-block-element-chooser-widget__link--subtitle" :title="type.title"
data-cy="choose-subtitle-widget" :type="type.type"
@click="changeType('subtitle')" :icon="type.icon"
> v-for="(type, idx) in filteredChooserTypes"
<title-icon class="content-block-element-chooser-widget__link-icon" /> :key="idx"
<div class="content-block-element-chooser-widget__link-title"> @select="changeType(type.block)"
Untertitel />
</div>
</div>
<div
class="content-block-element-chooser-widget__link content-block-element-chooser-widget__link--link"
data-cy="choose-link-widget"
@click="changeType('link_block')"
>
<link-icon class="content-block-element-chooser-widget__link-icon" />
<div class="content-block-element-chooser-widget__link-title">
Link
</div>
</div>
<div
class="content-block-element-chooser-widget__link content-block-element-chooser-widget__link--video"
data-cy="choose-video-widget"
@click="changeType('video_block')"
>
<video-icon class="content-block-element-chooser-widget__link-icon" />
<div class="content-block-element-chooser-widget__link-title">
Video
</div>
</div>
<div
class="content-block-element-chooser-widget__link content-block-element-chooser-widget__link--image"
data-cy="choose-image-widget"
@click="changeType('image_url_block')"
>
<image-icon class="content-block-element-chooser-widget__link-icon" />
<div class="content-block-element-chooser-widget__link-title">
Bild
</div>
</div>
<div
class="content-block-element-chooser-widget__link content-block-element-chooser-widget__link--text"
data-cy="choose-text-widget"
@click="changeType('text_block')"
>
<text-icon class="content-block-element-chooser-widget__link-icon" />
<div class="content-block-element-chooser-widget__link-title">
Text
</div>
</div>
<div
class="content-block-element-chooser-widget__link content-block-element-chooser-widget__link--assignment"
data-cy="choose-assignment-widget"
v-if="!hideAssignment"
@click="changeType('assignment')"
>
<speech-bubble-icon class="content-block-element-chooser-widget__link-icon" />
<div class="content-block-element-chooser-widget__link-title">
Aufgabe&nbsp;& Ergebnis
</div>
</div>
<div
class="content-block-element-chooser-widget__link content-block-element-chooser-widget__link--document"
data-cy="choose-document-widget"
@click="changeType('document_block')"
>
<document-icon class="content-block-element-chooser-widget__link-icon" />
<div class="content-block-element-chooser-widget__link-title">
Dokument
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -107,8 +44,10 @@
import Checkbox from '@/components/ui/Checkbox'; import Checkbox from '@/components/ui/Checkbox';
import formElementIcons from '@/components/ui/form-element-icons'; import formElementIcons from '@/components/ui/form-element-icons';
import TitleIcon from '@/components/icons/TitleIcon';
import CrossIcon from '@/components/icons/CrossIcon'; import CrossIcon from '@/components/icons/CrossIcon';
import ChooserElement from '@/components/content-forms/ChooserElement';
import {DEFAULT_FEATURE_SET} from '@/consts/features.consts';
export default { export default {
props: { props: {
@ -124,16 +63,72 @@
}, },
}, },
inject: ['features'],
components: { components: {
ChooserElement,
CrossIcon, CrossIcon,
TitleIcon,
Checkbox, Checkbox,
...formElementIcons, ...formElementIcons,
}, },
data: () => ({ data() {
convertToList: false, const hasDefaultFeatures = this.features === DEFAULT_FEATURE_SET;
}), return {
convertToList: false,
chooserTypes: [
{
type: 'subtitle',
block: 'subtitle',
title: 'Untertitel',
icon: 'title-icon',
},
{
type: 'link',
block: 'link_block',
title: 'Link',
icon: 'link-icon',
},
{
type: 'video',
block: 'video_block',
},
{
type: 'image',
block: 'image_url_block',
title: 'Bild',
},
{
type: 'text',
block: 'text_block',
},
{
type: 'assignment',
block: 'assignment',
icon: 'speech-bubble-icon',
title: 'Aufgabe & Ergebnis',
show: !this.hideAssignment && hasDefaultFeatures
},
{
type: 'document',
block: 'document_block',
title: 'Dokument',
show: hasDefaultFeatures
},
],
};
},
computed: {
filteredChooserTypes() {
return this.chooserTypes.filter(type => !("show" in type) || type.show ); // display element if `show` is not set or if `show` evaluates to true
},
hasDefaultFeatures() {
return this.features === DEFAULT_FEATURE_SET;
}
},
methods: { methods: {
changeType(type) { changeType(type) {
@ -144,7 +139,7 @@
}, },
remove() { remove() {
this.$emit('remove'); this.$emit('remove');
} },
}, },
}; };
</script> </script>
@ -222,29 +217,6 @@
grid-row: 1; grid-row: 1;
} }
&__link {
cursor: pointer;
border: 1px solid $color-silver;
border-radius: 4px;
height: 105px;
width: 105px;
box-sizing: border-box;
display: grid;
grid-template-rows: 1fr 45px;
justify-content: center;
justify-items: center;
align-items: center;
}
&__link-icon {
width: 40px;
height: 40px;
align-self: end;
}
&__link-text {
font-size: toRem(13px);
align-self: start;
}
} }
</style> </style>

View File

@ -3,7 +3,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100" viewBox="0 0 100 100"
id="shape" id="shape"
><title>cards</title> >
<path <path
d="M30.89,21v9.67H17.44v9.67H4V79H69.11V69.34H82.56V59.67H96V21Zm3.77,3.77H92.23V55.89H82.56V30.65H34.67ZM21.22,34.43H78.78V65.57H69.11V40.33H21.22ZM7.77,44.1H65.34V75.24H7.77Z" d="M30.89,21v9.67H17.44v9.67H4V79H69.11V69.34H82.56V59.67H96V21Zm3.77,3.77H92.23V55.89H82.56V30.65H34.67ZM21.22,34.43H78.78V65.57H69.11V40.33H21.22ZM7.77,44.1H65.34V75.24H7.77Z"
id="Fill-1" id="Fill-1"

View File

@ -1,29 +1,32 @@
<template> <template>
<div <router-link
class="add-room-entry-button" class="add-room-entry-button"
data-cy="add-room-entry-button" data-cy="add-room-entry-button"
@click="addRoomEntry" :to="addRoomEntryRoute"
> >
<plus-icon class="add-room-entry-button__icon" /> <plus-icon class="add-room-entry-button__icon" />
<span class="add-room-entry-button__text">Beitrag erfassen</span> <span class="add-room-entry-button__text">Beitrag erfassen</span>
</div> </router-link>
</template> </template>
<script> <script>
import { ADD_ROOM_ENTRY_PAGE } from '@/router/room.names';
const PlusIcon = () => import(/* webpackChunkName: "icons" */'@/components/icons/PlusIcon'); const PlusIcon = () => import(/* webpackChunkName: "icons" */'@/components/icons/PlusIcon');
export default { export default {
props: ['parent'], props: ['parent'],
components: { components: {
PlusIcon PlusIcon,
}, },
methods: { data() {
addRoomEntry() { return {
this.$store.dispatch('addRoomEntry', this.parent); addRoomEntryRoute: {
} name: ADD_ROOM_ENTRY_PAGE,
} },
};
},
}; };
</script> </script>

View File

@ -1,11 +1,12 @@
<template> <template>
<div class="entry-count-widget"> <div class="entry-count-widget">
<cards /> <component :is="icon" />
<span data-cy="entry-count">{{ entryCount }} <template v-if="verbose">{{ entryCount === 1 ? 'Beitrag' : 'Beiträge' }}</template></span> <span data-cy="entry-count">{{ entryCount }} <template v-if="verbose">{{ entryCount === 1 ? 'Beitrag' : 'Beiträge' }}</template></span>
</div> </div>
</template> </template>
<script> <script>
import SpeechBubbleIcon from '@/components/icons/SpeechBubbleIcon';
const Cards = () => import(/* webpackChunkName: "icons" */'@/components/icons/Cards.vue'); const Cards = () => import(/* webpackChunkName: "icons" */'@/components/icons/Cards.vue');
export default { export default {
@ -17,9 +18,15 @@
type: Boolean, type: Boolean,
default: true, default: true,
}, },
icon: {
type: String,
default: 'cards'
}
}, },
components: { components: {
'speech-bubble': SpeechBubbleIcon,
SpeechBubbleIcon,
Cards, Cards,
}, },
}; };

View File

@ -13,7 +13,7 @@
v-if="showMenu" v-if="showMenu"
@hide-me="showMenu = false" @hide-me="showMenu = false"
> >
<slot /> <slot :toggle="toggleMenu" />
</widget-popover> </widget-popover>
</div> </div>
</template> </template>
@ -59,9 +59,12 @@
} }
&__toggle { &__toggle {
background: white;
display: flex; display: flex;
border-radius: 5px; border-radius: 5px;
&--background {
background: white;
}
} }
} }
</style> </style>

View File

@ -21,7 +21,6 @@
</template> </template>
<script> <script>
import DELETE_ROOM_MUTATION from 'gql/mutations/rooms/deleteRoom.gql'; import DELETE_ROOM_MUTATION from 'gql/mutations/rooms/deleteRoom.gql';
import UPDATE_ROOM_VISIBILITY_MUTATION from 'gql/mutations/rooms/updateRoomVisibility.gql'; import UPDATE_ROOM_VISIBILITY_MUTATION from 'gql/mutations/rooms/updateRoomVisibility.gql';
@ -45,7 +44,6 @@
components: { components: {
MoreActions, MoreActions,
PopoverLink, PopoverLink,
}, },
methods: { methods: {

View File

@ -1,5 +1,8 @@
<template> <template>
<div class="room-entry"> <div
class="room-entry"
data-cy="room-entry"
>
<router-link <router-link
:to="{name: 'article', params: { slug: slug }}" :to="{name: 'article', params: { slug: slug }}"
class="room-entry__router-link" class="room-entry__router-link"
@ -24,18 +27,25 @@
class="room-entry__teaser" class="room-entry__teaser"
v-html="teaser" v-html="teaser"
/> />
<user-meta-widget <div class="room-entry__footer">
v-bind="author" <user-meta-widget
class="room-entry__author" v-bind="author"
/> class="room-entry__author"
/>
<entry-count-widget
:entry-count="comments"
icon="speech-bubble"
:verbose="false"
/>
</div>
</div> </div>
</router-link> </router-link>
<room-entry-actions <room-entry-actions
data-cy="room-entry-actions" data-cy="room-entry-actions"
class="room-entry__more" class="room-entry__more"
:slug="slug"
v-if="myEntry" v-if="myEntry"
:id="id"
/> />
</div> </div>
</template> </template>
@ -46,11 +56,13 @@
import UserMetaWidget from '@/components/UserMetaWidget'; import UserMetaWidget from '@/components/UserMetaWidget';
import teaser from '@/helpers/teaser'; import teaser from '@/helpers/teaser';
import RoomEntryActions from '@/components/rooms/RoomEntryActions'; import RoomEntryActions from '@/components/rooms/RoomEntryActions';
import EntryCountWidget from '@/components/rooms/EntryCountWidget';
export default { export default {
props: ['title', 'author', 'contents', 'slug', 'id'], props: ['title', 'author', 'contents', 'slug', 'id', 'comments'],
components: { components: {
EntryCountWidget,
RoomEntryActions, RoomEntryActions,
UserMetaWidget, UserMetaWidget,
}, },
@ -122,5 +134,12 @@
right: 10px; right: 10px;
} }
&__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
} }
</style> </style>

View File

@ -1,15 +1,19 @@
<template> <template>
<more-actions data-cy="room-entry-actions"> <more-actions
data-cy="room-entry-actions"
v-slot="{toggle}"
>
<popover-link <popover-link
icon="trash-icon" icon="trash-icon"
text="Eintrag löschen" text="Eintrag löschen"
@link-action="deleteRoomEntry(id)" data-cy="delete-room-entry"
@link-action="deleteRoomEntry(slug, toggle)"
/> />
<popover-link <popover-link
icon="pen-icon" icon="pen-icon"
data-cy="edit-room-entry" data-cy="edit-room-entry"
text="Eintrag bearbeiten" text="Eintrag bearbeiten"
@link-action="editRoomEntry(id)" @link-action="editRoomEntry(slug)"
/> />
</more-actions> </more-actions>
</template> </template>
@ -19,10 +23,12 @@
import MoreActions from '@/components/rooms/MoreActions'; import MoreActions from '@/components/rooms/MoreActions';
import DELETE_ROOM_ENTRY_MUTATION from 'gql/mutations/rooms/deleteRoomEntry'; import DELETE_ROOM_ENTRY_MUTATION from 'gql/mutations/rooms/deleteRoomEntry';
import ROOM_ENTRIES_QUERY from 'gql/queries/roomEntriesQuery'; import ROOM_ENTRIES_QUERY from 'gql/queries/roomEntriesQuery';
import {UPDATE_ROOM_ENTRY_PAGE} from '@/router/room.names';
import {removeAtIndex} from '@/graphql/immutable-operations';
export default { export default {
props: { props: {
id: { slug: {
type: String, type: String,
default: '', default: '',
}, },
@ -40,30 +46,51 @@
}, },
methods: { methods: {
deleteRoomEntry(id) { deleteRoomEntry(slug, toggle) {
this.$apollo.mutate({ toggle();
mutation: DELETE_ROOM_ENTRY_MUTATION, this.$modal.open('confirm')
variables: { .then(() => {
input: { this.$apollo.mutate({
id, mutation: DELETE_ROOM_ENTRY_MUTATION,
}, variables: {
}, input: {
update(store, {data: {deleteRoomEntry: {success, roomSlug}}}) { slug,
if (success) { },
const query = ROOM_ENTRIES_QUERY; },
const variables = {slug: roomSlug}; update(store, {data: {deleteRoomEntry: {success, roomSlug}}}) {
const data = store.readQuery({query, variables}); if (success) {
if (data) { const query = ROOM_ENTRIES_QUERY;
data.room.roomEntries.edges.splice(data.room.roomEntries.edges.findIndex(edge => edge.node.id === id), 1); const variables = {slug: roomSlug};
store.writeQuery({query, data, variables}); const {room} = store.readQuery({query, variables});
} if (room) {
} const index = room.roomEntries.edges.findIndex(edge => edge.node.slug === slug);
const edges = removeAtIndex(room.roomEntries.edges, index);
const data = {
room: {
...room,
roomEntries: {
edges,
},
},
};
store.writeQuery({query, data, variables});
}
}
},
});
})
.catch(() => {
});
},
editRoomEntry(slug) {
this.$router.push({
name: UPDATE_ROOM_ENTRY_PAGE,
params: {
slug: this.$route.params.slug,
entrySlug: slug,
}, },
}); });
}, },
editRoomEntry(id) {
this.$store.dispatch('editRoomEntry', id);
},
}, },
}; };
</script> </script>

View File

@ -1,7 +1,6 @@
<template> <template>
<li <li
class="popover-links__link" class="popover-links__link"
@click="$emit('link-action')"
> >
<a <a
class="popover-link" class="popover-link"

View File

@ -16,6 +16,7 @@
border-bottom-left-radius: $default-border-radius; border-bottom-left-radius: $default-border-radius;
border-bottom-right-radius: $default-border-radius; border-bottom-right-radius: $default-border-radius;
visibility: hidden; visibility: hidden;
padding-inline: $small-spacing;
@include desktop { @include desktop {
visibility: visible; visibility: visible;

View File

@ -0,0 +1,2 @@
export const DEFAULT_FEATURE_SET = 'default';
export const ROOMS_FEATURE_SET = 'rooms';

View File

@ -22,6 +22,9 @@ const typePolicies = {
InstrumentNode: { InstrumentNode: {
keyFields: ['slug'] keyFields: ['slug']
}, },
RoomNode: {
keyFields: ['slug']
},
ModuleNode: { ModuleNode: {
fields: { fields: {
inEditMode: { inEditMode: {

View File

@ -3,6 +3,9 @@ fragment RoomEntryParts on RoomEntryNode {
slug slug
title title
contents contents
comments {
id
}
author { author {
id id
firstName firstName

View File

@ -95,6 +95,7 @@ function networkErrorCallback(statusCode) {
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
Vue.$log.debug('navigation guard called', to, from); Vue.$log.debug('navigation guard called', to, from);
if (to.path === '/logout') { if (to.path === '/logout') {
Vue.$log.debug('logout', to);
publicApolloClient.resetStore(); publicApolloClient.resetStore();
if (process.env.LOGOUT_REDIRECT_URL) { if (process.env.LOGOUT_REDIRECT_URL) {
location.replace(`https://sso.hep-verlag.ch/logout?return_to=${process.env.LOGOUT_REDIRECT_URL}`); location.replace(`https://sso.hep-verlag.ch/logout?return_to=${process.env.LOGOUT_REDIRECT_URL}`);

View File

@ -40,6 +40,7 @@
const VideoBlock = () => import(/* webpackChunkName: "content-components" */'@/components/content-blocks/VideoBlock'); const VideoBlock = () => import(/* webpackChunkName: "content-components" */'@/components/content-blocks/VideoBlock');
const LinkBlock = () => import(/* webpackChunkName: "content-components" */'@/components/content-blocks/LinkBlock'); const LinkBlock = () => import(/* webpackChunkName: "content-components" */'@/components/content-blocks/LinkBlock');
const DocumentBlock = () => import(/* webpackChunkName: "content-components" */'@/components/content-blocks/DocumentBlock'); const DocumentBlock = () => import(/* webpackChunkName: "content-components" */'@/components/content-blocks/DocumentBlock');
const SubtitleBlock = () => import(/* webpackChunkName: "content-components" */'@/components/content-blocks/SubtitleBlock');
export default { export default {
components: { components: {
@ -51,6 +52,7 @@
'video_block': VideoBlock, 'video_block': VideoBlock,
'link_block': LinkBlock, 'link_block': LinkBlock,
'document_block': DocumentBlock, 'document_block': DocumentBlock,
'subtitle': SubtitleBlock,
UserMetaWidget, UserMetaWidget,
}, },

View File

@ -11,7 +11,7 @@
// todo: refactor this, we don't need 2 components, remove editRoom or EditRoom component // todo: refactor this, we don't need 2 components, remove editRoom or EditRoom component
import EditRoom from '@/components/rooms/EditRoom'; import EditRoom from '@/components/rooms/EditRoom';
import ROOM_QUERY from '@/graphql/gql/queries/roomQuery.gql'; import ROOM_QUERY from 'gql/queries/roomQuery.gql';
export default { export default {
props: ['id'], props: ['id'],

View File

@ -0,0 +1,110 @@
<template>
<content-block-form
:content-block="roomEntry"
:features="features"
v-if="roomEntry.id"
@save="save"
@back="goBack"
/>
</template>
<script>
import Vue from 'vue';
import ROOM_ENTRY_QUERY from 'gql/queries/roomEntryQuery.gql';
import ROOM_ENTRY_FRAGMENT from 'gql/fragments/roomEntryParts.gql';
import UPDATE_ROOM_ENTRY_MUTATION from 'gql/mutations/rooms/updateRoomEntry.gql';
import ContentBlockForm from '@/components/content-block-form/ContentBlockForm';
import {ROOMS_FEATURE_SET} from '@/consts/features.consts';
import {ROOM_PAGE} from '@/router/room.names';
export default Vue.extend( {
props: {
slug: {
type: String,
required: true
},
entrySlug: {
type: String,
required: true
}
},
components: {
ContentBlockForm,
},
data() {
return {
features: ROOMS_FEATURE_SET,
roomEntry: {
title: '',
contents: [],
},
};
},
apollo: {
roomEntry: {
query: ROOM_ENTRY_QUERY,
variables() {
return {
slug: this.entrySlug
};
}
}
},
methods: {
goBack() {
this.$router.push({
name: ROOM_PAGE,
params: {
slug: this.slug
}
});
},
save({title, contents}) {
const entry = {
slug: this.roomEntry.slug,
title,
contents,
};
this.$apollo.mutate({
mutation: UPDATE_ROOM_ENTRY_MUTATION,
variables: {
input: {
roomEntry: entry,
}
},
update: (store, {data: {updateRoomEntry: {roomEntry}}}) => {
try {
const fragment = ROOM_ENTRY_FRAGMENT;
const id = store.identify(roomEntry);
const cachedEntry = store.readQuery({fragment, id});
const data = Object.assign({}, cachedEntry, roomEntry);
store.writeFragment({
id,
fragment,
data
});
} catch (e) {
// Query did not exist in the cache, and apollo throws a generic Error. Do nothing
}
}
}).then(() => {
this.goBack();
});
}
},
} );
</script>
<style scoped lang="scss">
@import '~styles/helpers';
</style>

View File

@ -9,7 +9,7 @@
import RoomForm from '@/components/rooms/RoomForm'; import RoomForm from '@/components/rooms/RoomForm';
import ADD_ROOM_MUTATION from 'gql/mutations/rooms/addRoom.gql'; import ADD_ROOM_MUTATION from 'gql/mutations/rooms/addRoom.gql';
import ROOMS_QUERY from '@/graphql/gql/queries/roomsQuery.gql'; import ROOMS_QUERY from 'gql/queries/roomsQuery.gql';
const defaultAppearance = 'blue'; const defaultAppearance = 'blue';

View File

@ -1,58 +1,70 @@
<template> <template>
<contents-form <content-block-form
:content-block="entry" :content-block="roomEntry"
:show-task-selection="false" :features="features"
:disable-save="saving" @save="save"
data-cy="add-room-entry-modal" @back="goBack"
block-type="RoomEntry"
@save="saveEntry"
@hide="hideModal"
/> />
</template> </template>
<script> <script>
import Vue from 'vue';
import NEW_ROOM_ENTRY_MUTATION from 'gql/mutations/rooms/addRoomEntry.gql'; import NEW_ROOM_ENTRY_MUTATION from 'gql/mutations/rooms/addRoomEntry.gql';
import ROOM_ENTRIES_QUERY from '@/graphql/gql/queries/roomEntriesQuery.gql'; import ROOM_ENTRIES_QUERY from '@/graphql/gql/queries/roomEntriesQuery.gql';
import ContentsForm from '@/components/content-block-form/ContentsForm'; import ContentBlockForm from '@/components/content-block-form/ContentBlockForm';
import {ROOMS_FEATURE_SET} from '@/consts/features.consts';
import {ROOM_PAGE} from '@/router/room.names';
export default Vue.extend( {
props: {
slug: {
type: String,
required: true
}
},
export default {
components: { components: {
ContentsForm ContentBlockForm,
}, },
data() { data() {
return { return {
entry: { features: ROOMS_FEATURE_SET,
roomEntry: {
title: '', title: '',
contents: [] contents: [],
}, },
saving: false
}; };
}, },
computed: {
room() {
return this.$store.state.parentRoom;
}
},
methods: { methods: {
saveEntry(entry) { goBack() {
this.saving = true; this.$router.push({
name: ROOM_PAGE,
params: {
slug: this.slug
}
});
},
save({title, contents}) {
const entry = {
title,
contents,
roomSlug: this.slug
};
this.$apollo.mutate({ this.$apollo.mutate({
mutation: NEW_ROOM_ENTRY_MUTATION, mutation: NEW_ROOM_ENTRY_MUTATION,
variables: { variables: {
input: { input: {
roomEntry: Object.assign({}, entry, { roomEntry: entry,
room: this.room.id
})
} }
}, },
update: (store, {data: {addRoomEntry: {roomEntry}}}) => { update: (store, {data: {addRoomEntry: {roomEntry}}}) => {
try { try {
const query = ROOM_ENTRIES_QUERY; const query = ROOM_ENTRIES_QUERY;
const variables = {slug: this.room.slug}; const variables = {slug: this.slug};
const {room} = store.readQuery({query, variables}); const {room} = store.readQuery({query, variables});
if (room && room.roomEntries) { if (room && room.roomEntries) {
const newEdge ={ const newEdge ={
@ -79,13 +91,17 @@
} }
} }
}).then(() => { }).then(() => {
this.saving = false; this.goBack();
this.hideModal();
}); });
},
hideModal() { }
this.$store.dispatch('hideModal'); },
}, } );
}
};
</script> </script>
<style scoped lang="scss">
@import '~styles/helpers';
</style>

View File

@ -44,8 +44,9 @@
--> -->
</add-room-entry-button> </add-room-entry-button>
<room-entry <room-entry
v-for="entry in room.roomEntries"
v-bind="entry" v-bind="entry"
:comments="entry.comments.length"
v-for="entry in room.roomEntries"
:key="entry.id" :key="entry.id"
/> />
</div> </div>
@ -53,7 +54,7 @@
</template> </template>
<script> <script>
import ROOM_ENTRIES_QUERY from '@/graphql/gql/queries/roomEntriesQuery.gql'; import ROOM_ENTRIES_QUERY from 'gql/queries/roomEntriesQuery.gql';
import room from '@/mixins/room'; import room from '@/mixins/room';
import me from '@/mixins/me'; import me from '@/mixins/me';
import BackLink from '@/components/BackLink'; import BackLink from '@/components/BackLink';

View File

@ -22,8 +22,8 @@
</template> </template>
<script> <script>
import ROOMS_QUERY from '@/graphql/gql/queries/roomsQuery.gql'; import ROOMS_QUERY from 'gql/queries/roomsQuery.gql';
import ME_QUERY from '@/graphql/gql/queries/meQuery.gql'; import ME_QUERY from 'gql/queries/meQuery.gql';
import RoomWidget from '@/components/rooms/RoomWidget.vue'; import RoomWidget from '@/components/rooms/RoomWidget.vue';
import AddRoom from '@/components/rooms/AddRoom.vue'; import AddRoom from '@/components/rooms/AddRoom.vue';

View File

@ -1,2 +1,5 @@
export const NEW_ROOM_PAGE = 'new-room'; export const NEW_ROOM_PAGE = 'new-room';
export const ROOMS_PAGE = 'rooms'; export const ROOMS_PAGE = 'rooms';
export const ROOM_PAGE = 'room';
export const ADD_ROOM_ENTRY_PAGE = 'add-room-entry';
export const UPDATE_ROOM_ENTRY_PAGE = 'update-room-entry';

View File

@ -1,16 +1,20 @@
import {NEW_ROOM_PAGE, ROOMS_PAGE} from '@/router/room.names'; import {NEW_ROOM_PAGE, ROOMS_PAGE, ADD_ROOM_ENTRY_PAGE, ROOM_PAGE, UPDATE_ROOM_ENTRY_PAGE} from '@/router/room.names';
const rooms = () => import(/* webpackChunkName: "rooms" */'@/pages/rooms'); const rooms = () => import(/* webpackChunkName: "rooms" */'@/pages/rooms/rooms');
const newRoom = () => import(/* webpackChunkName: "rooms" */'@/pages/newRoom'); const newRoom = () => import(/* webpackChunkName: "rooms" */'@/pages/rooms/newRoom');
const editRoom = () => import(/* webpackChunkName: "rooms" */'@/pages/editRoom'); const editRoom = () => import(/* webpackChunkName: "rooms" */'@/pages/rooms/editRoom');
const room = () => import(/* webpackChunkName: "rooms" */'@/pages/room'); const room = () => import(/* webpackChunkName: "rooms" */'@/pages/rooms/room');
const newRoomEntry = () => import(/* webpackChunkName: "rooms" */'@/pages/rooms/newRoomEntry');
const editRoomEntry = () => import(/* webpackChunkName: "rooms" */'@/pages/rooms/editRoomEntry');
const moduleRoom = () => import(/* webpackChunkName: "rooms" */'@/pages/module/moduleRoom'); const moduleRoom = () => import(/* webpackChunkName: "rooms" */'@/pages/module/moduleRoom');
export default [ export default [
{path: '/rooms', name: ROOMS_PAGE, component: rooms, meta: {filter: true, hideFooter: true}}, {path: '/rooms', name: ROOMS_PAGE, component: rooms, meta: {filter: true, hideFooter: true}},
{path: '/new-room/', name: NEW_ROOM_PAGE, component: newRoom}, {path: '/new-room/', name: NEW_ROOM_PAGE, component: newRoom},
{path: '/edit-room/:id', name: 'edit-room', component: editRoom, props: true}, {path: '/edit-room/:id', name: 'edit-room', component: editRoom, props: true},
{path: '/room/:slug', name: 'room', component: room, props: true}, {path: '/room/:slug', name: ROOM_PAGE, component: room, props: true},
{path: '/room/:slug/add', name: ADD_ROOM_ENTRY_PAGE, component: newRoomEntry, props: true},
{path: '/room/:slug/edit/:entrySlug', name: UPDATE_ROOM_ENTRY_PAGE, component: editRoomEntry, props: true},
{ {
path: '/module-room/:slug', path: '/module-room/:slug',
name: 'moduleRoom', name: 'moduleRoom',

View File

@ -105,10 +105,6 @@ export default new Vuex.Store({
commit('setContentBlockPosition', payload); commit('setContentBlockPosition', payload);
dispatch('showModal', 'new-content-block-wizard'); dispatch('showModal', 'new-content-block-wizard');
}, },
addRoomEntry({commit, dispatch}, payload) {
commit('setParentRoom', payload);
dispatch('showModal', 'new-room-entry-wizard');
},
editRoomEntry({commit, dispatch}, payload) { editRoomEntry({commit, dispatch}, payload) {
commit('setCurrentRoomEntry', payload); commit('setCurrentRoomEntry', payload);
dispatch('showModal', 'edit-room-entry-wizard'); dispatch('showModal', 'edit-room-entry-wizard');

View File

@ -26,8 +26,8 @@ class RoomEntryArgument(InputObjectType):
class AddRoomEntryArgument(RoomEntryArgument): class AddRoomEntryArgument(RoomEntryArgument):
room = graphene.ID(required=True) room_slug = graphene.String(required=True)
class UpdateRoomEntryArgument(RoomEntryArgument): class UpdateRoomEntryArgument(RoomEntryArgument):
id = graphene.ID(required=True) slug = graphene.String(required=True)

View File

@ -3,7 +3,7 @@ from django.db import models
from django_extensions.db.models import TitleSlugDescriptionModel from django_extensions.db.models import TitleSlugDescriptionModel
from wagtail.core.fields import StreamField from wagtail.core.fields import StreamField
from books.blocks import DocumentBlock, ImageUrlBlock, LinkBlock, VideoBlock from books.blocks import DocumentBlock, ImageUrlBlock, LinkBlock, SubtitleBlock, VideoBlock
from books.models import TextBlock from books.models import TextBlock
from core.mixins import GraphqlNodeMixin from core.mixins import GraphqlNodeMixin
from users.models import SchoolClass from users.models import SchoolClass
@ -37,6 +37,7 @@ class RoomEntry(TitleSlugDescriptionModel):
('image_url_block', ImageUrlBlock()), ('image_url_block', ImageUrlBlock()),
('link_block', LinkBlock()), ('link_block', LinkBlock()),
('document_block', DocumentBlock()), ('document_block', DocumentBlock()),
('subtitle', SubtitleBlock()),
('video_block', VideoBlock()) ('video_block', VideoBlock())
], null=True, blank=True) ], null=True, blank=True)

View File

@ -86,12 +86,13 @@ class MutateRoomEntry(relay.ClientIDMutation):
def mutate_and_get_payload(cls, root, info, **kwargs): def mutate_and_get_payload(cls, root, info, **kwargs):
room_entry_data = kwargs.get('room_entry') room_entry_data = kwargs.get('room_entry')
room = None room = None
room_slug = room_entry_data.get('room_slug')
if room_entry_data.get('room') is not None: if room_slug is not None:
room = get_object(Room, room_entry_data.get('room')) room = Room.objects.get(slug=room_slug)
room_entry_data['room'] = room.id room_entry_data['room'] = room.id
if room_entry_data.get('id') is not None: if room_entry_data.get('slug') is not None:
serializer = cls.update_room_entry(info, room_entry_data) serializer = cls.update_room_entry(info, room_entry_data)
else: else:
serializer = cls.add_room_entry(info, room_entry_data, room) serializer = cls.add_room_entry(info, room_entry_data, room)
@ -105,19 +106,18 @@ class MutateRoomEntry(relay.ClientIDMutation):
@classmethod @classmethod
def update_room_entry(cls, info, room_entry_data): def update_room_entry(cls, info, room_entry_data):
instance = get_object(RoomEntry, room_entry_data.get('id')) instance = RoomEntry.objects.get(slug=room_entry_data.get('slug'))
if not instance.room.school_class.is_user_in_schoolclass(info.context.user): if not instance.room.school_class.is_user_in_schoolclass(info.context.user):
raise Exception('You are in the wrong class') raise Exception('You are in the wrong class')
if instance.author.pk != info.context.user.pk: if instance.author.pk != info.context.user.pk:
raise Exception('You are not the author') raise Exception('You are not the author')
return RoomEntrySerializer(instance, data=room_entry_data, partial=True) return RoomEntrySerializer(instance, data=room_entry_data, partial=True)
@classmethod @classmethod
def add_room_entry(cls, info, room_entry_data, room): def add_room_entry(cls, info, room_entry_data, room):
if not room or not room.school_class.is_user_in_schoolclass(info.context.user): if not room or not room.school_class.is_user_in_schoolclass(info.context.user):
raise PermissionDenied('You are in the wrong class') raise PermissionDenied('You are in the wrong class')
@ -137,7 +137,7 @@ class UpdateRoomEntry(MutateRoomEntry):
class DeleteRoomEntry(relay.ClientIDMutation): class DeleteRoomEntry(relay.ClientIDMutation):
class Input: class Input:
id = graphene.ID(required=True) slug = graphene.String(required=True)
success = graphene.Boolean() success = graphene.Boolean()
room_slug = graphene.String() room_slug = graphene.String()
@ -146,8 +146,8 @@ class DeleteRoomEntry(relay.ClientIDMutation):
@classmethod @classmethod
def mutate_and_get_payload(cls, root, info, **kwargs): def mutate_and_get_payload(cls, root, info, **kwargs):
id = kwargs.get('id') slug = kwargs.get('slug')
room_entry = get_object(RoomEntry, id) room_entry = RoomEntry.objects.get(slug=slug)
if room_entry.author.pk != info.context.user.pk: if room_entry.author.pk != info.context.user.pk:
raise Exception('You are not the owner of this room entry') raise Exception('You are not the owner of this room entry')
room_id = to_global_id('RoomNode', room_entry.room.pk) room_id = to_global_id('RoomNode', room_entry.room.pk)
@ -170,7 +170,8 @@ class UpdateRoomVisibility(relay.ClientIDMutation):
restricted = kwargs.get('restricted') restricted = kwargs.get('restricted')
user = info.context.user user = info.context.user
room = get_object(Room, id) room = get_object(Room, id)
if not user.is_teacher() or not SchoolClassMember.objects.filter(active=True,user=user,school_class=room.school_class).exists(): if not user.is_teacher() or not SchoolClassMember.objects.filter(active=True, user=user,
school_class=room.school_class).exists():
raise Exception('You are not permitted to do this') raise Exception('You are not permitted to do this')
room.restricted = restricted room.restricted = restricted
room.save() room.save()
@ -197,7 +198,6 @@ class AddComment(relay.ClientIDMutation):
return cls(success=True, comment=comment) return cls(success=True, comment=comment)
class RoomMutations: class RoomMutations:
update_room = UpdateRoom.Field() update_room = UpdateRoom.Field()
add_room = AddRoom.Field() add_room = AddRoom.Field()

View File

@ -9,6 +9,32 @@ from rooms.factories import RoomEntryFactory, RoomFactory
from rooms.models import RoomEntry from rooms.models import RoomEntry
from users.factories import SchoolClassFactory from users.factories import SchoolClassFactory
ADD_ROOM_ENTRY_MUTATION = """
fragment RoomEntryParts on RoomEntryNode {
id
slug
title
contents
author {
id
firstName
lastName
avatarUrl
}
}
mutation AddRoomEntry($input: AddRoomEntryInput!){
addRoomEntry(input: $input) {
roomEntry {
...RoomEntryParts
}
errors
}
}
"""
class RoomEntryMutationsTestCase(SkillboxTestCase): class RoomEntryMutationsTestCase(SkillboxTestCase):
def setUp(self): def setUp(self):
@ -39,7 +65,7 @@ class RoomEntryMutationsTestCase(SkillboxTestCase):
result = self.client.execute(mutation, variables={ result = self.client.execute(mutation, variables={
'input': { 'input': {
'id': to_global_id('RoomEntryNode', self.room_entry.pk) 'slug': self.room_entry.slug
} }
}) })
self.assertIsNone(result.get('errors')) self.assertIsNone(result.get('errors'))
@ -141,41 +167,16 @@ class RoomEntryMutationsTestCase(SkillboxTestCase):
def test_add_room_entry_not_owner_from_other_class(self): def test_add_room_entry_not_owner_from_other_class(self):
self.assertEqual(RoomEntry.objects.count(), 1) self.assertEqual(RoomEntry.objects.count(), 1)
mutation = """
fragment RoomEntryParts on RoomEntryNode {
id
slug
title
contents
author {
id
firstName
lastName
avatarUrl
}
}
mutation AddRoomEntry($input: AddRoomEntryInput!){
addRoomEntry(input: $input) {
roomEntry {
...RoomEntryParts
}
errors
}
}
"""
# input: # input:
# title = graphene.String(required=True) # title = graphene.String(required=True)
# contents = graphene.List(ContentElementInput) # contents = graphene.List(ContentElementInput)
# room = graphene.ID(required=True) # room = graphene.ID(required=True)
room_entry = { room_entry = {
'title': 'Bad Actor!', 'title': 'Bad Actor!',
'room': self.room.graphql_id 'roomSlug': self.room.slug
} }
result = self.get_client(self.yet_another_user).execute(mutation, variables={ result = self.get_client(self.yet_another_user).execute(ADD_ROOM_ENTRY_MUTATION, variables={
'input': { 'input': {
'roomEntry': room_entry 'roomEntry': room_entry
} }
@ -183,3 +184,26 @@ mutation AddRoomEntry($input: AddRoomEntryInput!){
self.assertIsNotNone(result.errors) self.assertIsNotNone(result.errors)
self.assertTrue('message' in result.errors[0]) self.assertTrue('message' in result.errors[0])
self.assertEqual(result.errors[0]['message'], 'You are in the wrong class') self.assertEqual(result.errors[0]['message'], 'You are in the wrong class')
def test_add_room_entry(self):
self.assertEqual(RoomEntry.objects.count(), 1)
text_block = {"type": "text_block", "value": {"text": "<p>some text</p>"}}
subtitle_block = {"type": "subtitle", "value": {"text": "A subtitle"}}
room_entry = {
'title': 'A room entry',
'roomSlug': self.room.slug,
'contents': [text_block, subtitle_block]
}
result = self.get_client(self.user).execute(ADD_ROOM_ENTRY_MUTATION, variables={
'input': {
'roomEntry': room_entry
}
})
self.assertIsNone(result.errors)
room_entry_data = result.data.get('addRoomEntry').get('roomEntry')
contents = room_entry_data.get('contents')
self.assertEqual(len(contents), 2)
text, subtitle = contents
self.assertEqual(text.get('type'), 'text_block')
self.assertEqual(subtitle.get('type'), 'subtitle')
self.assertEqual(RoomEntry.objects.count(), 2)

View File

@ -118,7 +118,7 @@ input AddRoomArgument {
input AddRoomEntryArgument { input AddRoomEntryArgument {
title: String! title: String!
contents: [ContentElementInput] contents: [ContentElementInput]
room: ID! roomSlug: String!
} }
input AddRoomEntryInput { input AddRoomEntryInput {
@ -421,7 +421,7 @@ type DeleteProjectPayload {
} }
input DeleteRoomEntryInput { input DeleteRoomEntryInput {
id: ID! slug: String!
clientMutationId: String clientMutationId: String
} }
@ -1401,7 +1401,7 @@ input UpdateRoomArgument {
input UpdateRoomEntryArgument { input UpdateRoomEntryArgument {
title: String! title: String!
contents: [ContentElementInput] contents: [ContentElementInput]
id: ID! slug: String!
} }
input UpdateRoomEntryInput { input UpdateRoomEntryInput {