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', () => {
const slug = 'this-article-has-a-slug';
const roomEntry = {
slug,
id: 'room-entry-id',
title: 'Some Room Entry, yay!',
comments: [],
};
slug,
id: 'room-entry-id',
title: 'Some Room Entry, yay!',
comments: [],
contents: [{
type: 'text_block',
value: {
text: 'Ein Text',
},
}, {
type: 'subtitle',
value: {
text: 'Ein Untertitel'
}
}],
};
const operations = {
MeQuery: getMinimalMe({}),
@ -23,18 +34,28 @@ describe('Article page', () => {
roomEntry: roomEntry,
owner: {
firstName: 'Matt',
lastName: 'Damon'
}
}
}
lastName: 'Damon',
},
},
},
};
}
},
};
beforeEach(() => {
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', () => {
cy.mockGraphqlOps({
operations,

View File

@ -1,6 +1,6 @@
import {getMinimalMe} from '../../../support/helpers';
describe('The Room Page', () => {
describe('The Room Page (Teacher)', () => {
const MeQuery = getMinimalMe();
const selectedClass = MeQuery.me.selectedClass;
const entryText = 'something should be here';
@ -61,32 +61,17 @@ describe('The Room Page', () => {
});
cy.visit(`/room/${slug}`);
cy.get('[data-cy=add-room-entry-button]').click();
cy.get('.add-content-element:first-of-type').click();
cy.get('[data-cy=choose-text-widget]').click();
cy.get('[data-cy=modal-title-input] > .modal-input__inputfield').type(entryTitle);
cy.getByDataCy('add-room-entry-button').click();
cy.getByDataCy('add-content-link').first().click();
cy.getByDataCy('choose-text-widget').click();
cy.getByDataCy('input-with-label-input').type(entryTitle);
cy.get('[data-cy=text-form-input]').type(entryText);
cy.get('[data-cy=modal-save-button]').click();
cy.get('.tip-tap__editor').type(entryText);
cy.getByDataCy('save-button').click();
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
it.skip('changes visibility of a room', () => {
const MeQuery = getMinimalMe({
@ -149,6 +134,7 @@ describe('The Room Page', () => {
};
const otherRoom = {
id: btoa('RoomNode:otherRoom'),
slug: 'other-slug',
schoolClass,
};
let rooms = [roomToDelete, otherRoom];
@ -156,7 +142,7 @@ describe('The Room Page', () => {
MeQuery,
RoomsQuery() {
return {
rooms
rooms,
};
},
RoomEntriesQuery: {
@ -176,47 +162,126 @@ describe('The Room Page', () => {
cy.getByDataCy('room-widget').should('have.length', 2);
cy.getByDataCy('room-widget').first().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.getByDataCy('room-widget').should('have.length', 1);
});
it('edits own room entry', () => {
const MeQuery = getMinimalMe({isTeacher: false});
it('changes class while on room page', () => {
const {me} = MeQuery;
const id = atob(me.id).split(':')[1];
const authorId = btoa(`PublicUserNode:${id}`);
const room = {
id: 'some-room',
roomEntries: {
edges: [
{
node: {
id: '',
slug: '',
contents: [],
author: {
...me,
id: authorId,
},
},
},
],
},
const otherClass = {
id: btoa('SchoolClassNode:34'),
name: 'Other Class',
readOnly: false,
};
let selectedClass = me.selectedClass;
const operations = {
MeQuery: MeQuery,
RoomEntriesQuery: {
room,
MeQuery: () => {
return {
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({
operations,
});
cy.visit(`/room/${slug}`);
cy.getByDataCy('room-entry-actions').click();
cy.getByDataCy('edit-room-entry').click();
cy.getByDataCy('room-title').should('contain', 'A Room');
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', () => {
@ -240,56 +305,83 @@ describe('The Room Page', () => {
cy.visit(`/room/${slug}`);
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', () => {
const {me} = MeQuery;
const otherClass = {
id: btoa('SchoolClassNode:34'),
name: 'Other Class',
readOnly: false
it('edits own room entry', () => {
const room = {
id: 'some-room',
slug,
// schoolClass: me.selectedClass,
roomEntries: {
edges: [
{
node: roomEntry,
},
],
},
};
let selectedClass = me.selectedClass;
const operations = {
MeQuery: () => {
return {
me: {
...me,
schoolClasses: [...me.schoolClasses, otherClass],
selectedClass
},
};
MeQuery: MeQuery,
RoomEntriesQuery: {
room,
},
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,
UpdateSettings() {
selectedClass = otherClass;
return {
updateSettings: {
success: true
}
};
},
ModuleDetailsQuery: {},
MySchoolClassQuery: () => {
return {
me: {
selectedClass
}
};
},
RoomsQuery: {
rooms: []
}
DeleteRoomEntry,
};
cy.mockGraphqlOps({
operations,
});
cy.visit(`/room/${slug}`);
cy.getByDataCy('room-title').should('contain', 'A Room');
cy.selectClass('Other Class');
cy.url().should('include', 'rooms');
cy.getByDataCy('current-class-name').should('contain', 'Other Class');
cy.getByDataCy('room-entry').should('have.length', 1);
cy.getByDataCy('room-entry-actions').click();
cy.getByDataCy('delete-room-entry').click();
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 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 NewProjectEntryWizard = () => import(/* webpackChunkName: "content-forms" */'@/components/portfolio/NewProjectEntryWizard');
const EditProjectEntryWizard = () => import(/* webpackChunkName: "content-forms" */'@/components/portfolio/EditProjectEntryWizard');
@ -58,7 +57,6 @@
SplitLayout,
NewContentBlockWizard,
EditContentBlockWizard,
NewRoomEntryWizard,
EditRoomEntryWizard,
NewProjectEntryWizard,
EditProjectEntryWizard,

View File

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

View File

@ -4,7 +4,10 @@
<component
class="content-form-section__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>
<content-element-actions

View File

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

View File

@ -2,6 +2,7 @@
<!-- eslint-disable vue/no-v-html -->
<div
class="text-block"
data-cy="text-block"
v-html="sanitizedText"
/>
</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
</h3>
<template
v-if="includeListOption"
v-if="includeListOption && hasDefaultFeatures"
>
<checkbox
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"
>
<div
class="content-block-element-chooser-widget__link content-block-element-chooser-widget__link--subtitle"
data-cy="choose-subtitle-widget"
@click="changeType('subtitle')"
>
<title-icon class="content-block-element-chooser-widget__link-icon" />
<div class="content-block-element-chooser-widget__link-title">
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>
<chooser-element
:title="type.title"
:type="type.type"
:icon="type.icon"
v-for="(type, idx) in filteredChooserTypes"
:key="idx"
@select="changeType(type.block)"
/>
</div>
</div>
</template>
@ -107,8 +44,10 @@
import Checkbox from '@/components/ui/Checkbox';
import formElementIcons from '@/components/ui/form-element-icons';
import TitleIcon from '@/components/icons/TitleIcon';
import CrossIcon from '@/components/icons/CrossIcon';
import ChooserElement from '@/components/content-forms/ChooserElement';
import {DEFAULT_FEATURE_SET} from '@/consts/features.consts';
export default {
props: {
@ -124,16 +63,72 @@
},
},
inject: ['features'],
components: {
ChooserElement,
CrossIcon,
TitleIcon,
Checkbox,
...formElementIcons,
},
data: () => ({
convertToList: false,
}),
data() {
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: {
changeType(type) {
@ -144,7 +139,7 @@
},
remove() {
this.$emit('remove');
}
},
},
};
</script>
@ -222,29 +217,6 @@
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>

View File

@ -3,7 +3,7 @@
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
id="shape"
><title>cards</title>
>
<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"
id="Fill-1"

View File

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

View File

@ -1,11 +1,12 @@
<template>
<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>
</div>
</template>
<script>
import SpeechBubbleIcon from '@/components/icons/SpeechBubbleIcon';
const Cards = () => import(/* webpackChunkName: "icons" */'@/components/icons/Cards.vue');
export default {
@ -17,9 +18,15 @@
type: Boolean,
default: true,
},
icon: {
type: String,
default: 'cards'
}
},
components: {
'speech-bubble': SpeechBubbleIcon,
SpeechBubbleIcon,
Cards,
},
};

View File

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

View File

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

View File

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

View File

@ -1,15 +1,19 @@
<template>
<more-actions data-cy="room-entry-actions">
<more-actions
data-cy="room-entry-actions"
v-slot="{toggle}"
>
<popover-link
icon="trash-icon"
text="Eintrag löschen"
@link-action="deleteRoomEntry(id)"
data-cy="delete-room-entry"
@link-action="deleteRoomEntry(slug, toggle)"
/>
<popover-link
icon="pen-icon"
data-cy="edit-room-entry"
text="Eintrag bearbeiten"
@link-action="editRoomEntry(id)"
@link-action="editRoomEntry(slug)"
/>
</more-actions>
</template>
@ -19,10 +23,12 @@
import MoreActions from '@/components/rooms/MoreActions';
import DELETE_ROOM_ENTRY_MUTATION from 'gql/mutations/rooms/deleteRoomEntry';
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 {
props: {
id: {
slug: {
type: String,
default: '',
},
@ -40,30 +46,51 @@
},
methods: {
deleteRoomEntry(id) {
this.$apollo.mutate({
mutation: DELETE_ROOM_ENTRY_MUTATION,
variables: {
input: {
id,
},
},
update(store, {data: {deleteRoomEntry: {success, roomSlug}}}) {
if (success) {
const query = ROOM_ENTRIES_QUERY;
const variables = {slug: roomSlug};
const data = store.readQuery({query, variables});
if (data) {
data.room.roomEntries.edges.splice(data.room.roomEntries.edges.findIndex(edge => edge.node.id === id), 1);
store.writeQuery({query, data, variables});
}
}
deleteRoomEntry(slug, toggle) {
toggle();
this.$modal.open('confirm')
.then(() => {
this.$apollo.mutate({
mutation: DELETE_ROOM_ENTRY_MUTATION,
variables: {
input: {
slug,
},
},
update(store, {data: {deleteRoomEntry: {success, roomSlug}}}) {
if (success) {
const query = ROOM_ENTRIES_QUERY;
const variables = {slug: roomSlug};
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>

View File

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

View File

@ -16,6 +16,7 @@
border-bottom-left-radius: $default-border-radius;
border-bottom-right-radius: $default-border-radius;
visibility: hidden;
padding-inline: $small-spacing;
@include desktop {
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: {
keyFields: ['slug']
},
RoomNode: {
keyFields: ['slug']
},
ModuleNode: {
fields: {
inEditMode: {

View File

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

View File

@ -95,6 +95,7 @@ function networkErrorCallback(statusCode) {
router.beforeEach(async (to, from, next) => {
Vue.$log.debug('navigation guard called', to, from);
if (to.path === '/logout') {
Vue.$log.debug('logout', to);
publicApolloClient.resetStore();
if (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 LinkBlock = () => import(/* webpackChunkName: "content-components" */'@/components/content-blocks/LinkBlock');
const DocumentBlock = () => import(/* webpackChunkName: "content-components" */'@/components/content-blocks/DocumentBlock');
const SubtitleBlock = () => import(/* webpackChunkName: "content-components" */'@/components/content-blocks/SubtitleBlock');
export default {
components: {
@ -51,6 +52,7 @@
'video_block': VideoBlock,
'link_block': LinkBlock,
'document_block': DocumentBlock,
'subtitle': SubtitleBlock,
UserMetaWidget,
},

View File

@ -11,7 +11,7 @@
// todo: refactor this, we don't need 2 components, remove editRoom or EditRoom component
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 {
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 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';

View File

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

View File

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

View File

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

View File

@ -1,2 +1,5 @@
export const NEW_ROOM_PAGE = 'new-room';
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 newRoom = () => import(/* webpackChunkName: "rooms" */'@/pages/newRoom');
const editRoom = () => import(/* webpackChunkName: "rooms" */'@/pages/editRoom');
const room = () => import(/* webpackChunkName: "rooms" */'@/pages/room');
const rooms = () => import(/* webpackChunkName: "rooms" */'@/pages/rooms/rooms');
const newRoom = () => import(/* webpackChunkName: "rooms" */'@/pages/rooms/newRoom');
const editRoom = () => import(/* webpackChunkName: "rooms" */'@/pages/rooms/editRoom');
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');
export default [
{path: '/rooms', name: ROOMS_PAGE, component: rooms, meta: {filter: true, hideFooter: true}},
{path: '/new-room/', name: NEW_ROOM_PAGE, component: newRoom},
{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',
name: 'moduleRoom',

View File

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

View File

@ -26,8 +26,8 @@ class RoomEntryArgument(InputObjectType):
class AddRoomEntryArgument(RoomEntryArgument):
room = graphene.ID(required=True)
room_slug = graphene.String(required=True)
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 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 core.mixins import GraphqlNodeMixin
from users.models import SchoolClass
@ -37,6 +37,7 @@ class RoomEntry(TitleSlugDescriptionModel):
('image_url_block', ImageUrlBlock()),
('link_block', LinkBlock()),
('document_block', DocumentBlock()),
('subtitle', SubtitleBlock()),
('video_block', VideoBlock())
], null=True, blank=True)

View File

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

View File

@ -9,6 +9,32 @@ from rooms.factories import RoomEntryFactory, RoomFactory
from rooms.models import RoomEntry
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):
def setUp(self):
@ -39,7 +65,7 @@ class RoomEntryMutationsTestCase(SkillboxTestCase):
result = self.client.execute(mutation, variables={
'input': {
'id': to_global_id('RoomEntryNode', self.room_entry.pk)
'slug': self.room_entry.slug
}
})
self.assertIsNone(result.get('errors'))
@ -141,41 +167,16 @@ class RoomEntryMutationsTestCase(SkillboxTestCase):
def test_add_room_entry_not_owner_from_other_class(self):
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:
# title = graphene.String(required=True)
# contents = graphene.List(ContentElementInput)
# room = graphene.ID(required=True)
room_entry = {
'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': {
'roomEntry': room_entry
}
@ -183,3 +184,26 @@ mutation AddRoomEntry($input: AddRoomEntryInput!){
self.assertIsNotNone(result.errors)
self.assertTrue('message' in result.errors[0])
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 {
title: String!
contents: [ContentElementInput]
room: ID!
roomSlug: String!
}
input AddRoomEntryInput {
@ -421,7 +421,7 @@ type DeleteProjectPayload {
}
input DeleteRoomEntryInput {
id: ID!
slug: String!
clientMutationId: String
}
@ -1401,7 +1401,7 @@ input UpdateRoomArgument {
input UpdateRoomEntryArgument {
title: String!
contents: [ContentElementInput]
id: ID!
slug: String!
}
input UpdateRoomEntryInput {