Add form for editing project entries

This commit is contained in:
Ramon Wenger 2019-05-13 17:57:21 +02:00
parent dda9f75011
commit 175b517e75
20 changed files with 346 additions and 119 deletions

View File

@ -0,0 +1,44 @@
describe('Project Entry', () => {
beforeEach(() => {
cy.exec("python ../server/manage.py prepare_projects_for_cypress");
cy.viewport('macbook-15');
cy.startGraphQLCapture();
cy.login('rahel.cueni', 'test');
});
it('should create a new project entry', () => {
cy.visit('/portfolio');
cy.get('[data-cy=project-link]:first-of-type').click();
cy.get('[data-cy=add-project-entry]:first-of-type').click();
cy.get('[data-cy=activity-input]').within(() => {
cy.get('[data-cy=text-form-input]').type('Join the Guardians');
});
cy.get('[data-cy=reflection-input]').within(() => {
cy.get('[data-cy=text-form-input]').type('They are cool!');
});
cy.get('[data-cy=next-steps-input]').within(() => {
cy.get('[data-cy=text-form-input]').type('Stay with Rocket\nMeet Quill');
});
cy.get('[data-cy=modal-save-button]').click();
cy.waitFor('AddProjectEntryMutation');
cy.get('.project-entry:last-of-type').within(() => {
cy.get('.project-entry__paragraph:first-of-type').contains('Join the Guardians')
});
});
it('should edit first entry', () => {
cy.visit('/portfolio/groot');
cy.get('.project-entry__paragraph:first-of-type').contains('Kill Thanos');
cy.get('.project-entry:first-of-type').within(() => {
cy.get('[data-cy=project-entry-more]').click();
cy.get('[data-cy=edit-project-entry]').click();
});
cy.get('[data-cy=activity-input]').within(() => {
cy.get('[data-cy=text-form-input]').clear().type('Defeat Thanos');
});
cy.get('[data-cy=modal-save-button]').click();
cy.waitFor('UpdateProjectEntry');
cy.get('.project-entry__paragraph:first-of-type').contains('Defeat Thanos');
})
});

View File

@ -19,6 +19,7 @@
import NewObjectiveGroupWizard from '@/components/objective-groups/NewObjectiveGroupWizard';
import EditObjectiveGroupWizard from '@/components/objective-groups/EditObjectiveGroupWizard';
import NewProjectEntryWizard from '@/components/portfolio/NewProjectEntryWizard';
import EditProjectEntryWizard from '@/components/portfolio/EditProjectEntryWizard';
import FullscreenImage from '@/components/FullscreenImage';
import FullscreenInfographic from '@/components/FullscreenInfographic';
import FullscreenVideo from '@/components/FullscreenVideo';
@ -41,6 +42,7 @@
NewObjectiveGroupWizard,
EditObjectiveGroupWizard,
NewProjectEntryWizard,
EditProjectEntryWizard,
FullscreenImage,
FullscreenInfographic,
FullscreenVideo

View File

@ -0,0 +1,59 @@
<template>
<div class="more-options">
<a @click="showMenu = !showMenu" class="more-options__more-link">
<ellipses class="more-options__ellipses"></ellipses>
</a>
<widget-popover @hide-me="showMenu = false"
class="more-options__popover"
v-if="showMenu">
<slot></slot>
</widget-popover>
</div>
</template>
<script>
import WidgetPopover from '@/components/rooms/WidgetPopover';
import Ellipses from '@/components/icons/Ellipses.vue';
export default {
components: {
WidgetPopover,
Ellipses
},
data() {
return {
showMenu: false
}
}
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
.more-options {
display: flex;
justify-content: flex-end;
&__ellipses {
width: 30px;
height: 30px;
fill: $color-darkgrey-1;
margin-top: -7px;
}
&__more-link {
background-color: rgba($color-white, 0.9);
width: 35px;
height: 15px;
border-radius: 15px;
display: flex;
justify-content: center;
}
&__popover {
width: 180px;
}
}
</style>

View File

@ -14,7 +14,7 @@
computed: {
text() {
return this.value.text.replace(/<br(\/)?>/, '\n').replace(/(<([^>]+)>)/ig, '')
return this.value.text ? this.value.text.replace(/<br(\/)?>/, '\n').replace(/(<([^>]+)>)/ig, '') : '';
}
}
}

View File

@ -0,0 +1,58 @@
<template>
<project-entry-form :project-entry="projectEntry" @save="saveEntry" @hide="hideModal">
</project-entry-form>
</template>
<script>
import ProjectEntryForm from './ProjectEntryForm';
import {mapGetters} from 'vuex';
import PROJECT_ENTRY_QUERY from '@/graphql/gql/projectEntryQuery.gql';
import UPDATE_PROJECT_ENTRY_MUTATION from '@/graphql/gql/mutations/updateProjectEntry.gql';
export default {
components: {
ProjectEntryForm
},
data() {
return {
projectEntry: {}
}
},
apollo: {
projectEntry() {
return {
query: PROJECT_ENTRY_QUERY,
variables: {
id: this.currentProjectEntry
}
}
}
},
computed: {
...mapGetters(['currentProjectEntry'])
},
methods: {
saveEntry(entry) {
this.$apollo.mutate({
mutation: UPDATE_PROJECT_ENTRY_MUTATION,
variables: {
input: {
projectEntry: entry
}
}
}).then(() => {
this.hideModal();
});
},
hideModal() {
this.$store.dispatch('hideModal');
}
},
}
</script>

View File

@ -1,37 +1,17 @@
<template>
<modal :hide-header="true">
<div class="project-entry-modal">
<text-form-with-help-text title="Tätigkeit" :value="activity" @change="activity = $event"
help-text="Was? Wie? Mittel?">
</text-form-with-help-text>
<text-form-with-help-text title="Reflexion" :value="reflection" @change="reflection = $event"
help-text="Nachdenken über die eigene Tätigkeit und das eigene Handeln. Was ging gut? Was hatte ich für Schwierigkeiten? Was habe ich gelernt?">
</text-form-with-help-text>
<text-form-with-help-text title="Nächste Schritte" :value="nextSteps" @change="nextSteps = $event"
help-text="Wie geht es weiter? Wer macht was?">
</text-form-with-help-text>
<document-form :value="document" :index="0" @link-change-url="setDocumentUrl"></document-form>
</div>
<div slot="footer">
<a class="button button--primary" data-cy="modal-save-button" v-on:click="save">Speichern</a>
<a class="button" v-on:click="hideModal">Abbrechen</a>
</div>
</modal>
<project-entry-form @save="save" @hide="hideModal" :project-entry="projectEntry">
</project-entry-form>
</template>
<script>
import Modal from '@/components/Modal';
import TextFormWithHelpText from '@/components/content-forms/TextFormWithHelpText';
import ProjectEntryForm from './ProjectEntryForm';
import NEW_PROJECT_ENTRY_MUTATION from '@/graphql/gql/mutations/addProjectEntry.gql';
import PROJECT_QUERY from '@/graphql/gql/projectQuery.gql';
import DocumentForm from '@/components/content-forms/DocumentForm';
export default {
components: {
DocumentForm,
Modal,
TextFormWithHelpText
ProjectEntryForm
},
computed: {
@ -40,30 +20,18 @@
},
slug() {
return this.$route.params.slug;
},
document() {
return this.documentUrl > '' ? {
url: this.documentUrl
} : {};
}
},
methods: {
setDocumentUrl(url) {
this.documentUrl = url;
},
save() {
save(entry) {
this.$apollo.mutate({
mutation: NEW_PROJECT_ENTRY_MUTATION,
variables: {
input: {
projectEntry: Object.assign({}, {
nextSteps: this.nextSteps,
activity: this.activity,
reflection: this.reflection,
documentUrl: this.documentUrl,
projectEntry: Object.assign({
project: this.project
})
}, entry)
}
},
update: (store, {data: {addProjectEntry: {projectEntry}}}) => {
@ -71,7 +39,7 @@
const variables = {slug: this.slug};
const data = store.readQuery({query, variables});
if (data.project && data.project.entries) {
data.project.entries.edges.unshift({
data.project.entries.edges.push({
node: projectEntry,
__typename: 'ProjectEntryNode'
});
@ -90,10 +58,12 @@
data() {
return {
activity: '',
reflection: '',
nextSteps: '',
documentUrl: ''
projectEntry: {
activity: '',
reflection: '',
nextSteps: '',
documentUrl: ''
}
}
}
}

View File

@ -1,7 +1,11 @@
<template>
<div class="project-entry">
<more-options-widget class="project-entry__more" data-cy="project-entry-more">
<li class="popover-links__link"><a @click="editProjectEntry()" data-cy="edit-project-entry">Eintrag bearbeiten</a></li>
</more-options-widget>
<h3 class="project-entry__heading">Tätigkeit</h3>
<p class="project-entry__paragraph">
<p class="project-entry__paragraph" data-cy="project-entry-activity">
{{activity}}
</p>
<h3 class="project-entry__heading">Reflexion</h3>
@ -25,12 +29,20 @@
<script>
import DocumentBlock from '@/components/content-blocks/DocumentBlock';
import MoreOptionsWidget from '@/components/MoreOptionsWidget';
export default {
components: {
DocumentBlock
DocumentBlock,
MoreOptionsWidget
},
props: ['activity', 'reflection', 'nextSteps', 'documentUrl', 'created']
props: ['activity', 'reflection', 'nextSteps', 'documentUrl', 'created', 'id'],
methods: {
editProjectEntry() {
this.$store.dispatch('editProjectEntry', this.id);
}
}
}
</script>
@ -43,6 +55,7 @@
background-color: $color-white;
border-radius: $default-border-radius;
padding: 30px 20px;
position: relative;
&__heading {
font-size: toRem(22px);
@ -63,5 +76,12 @@
cursor: pointer;
@include heading-4;
}
&__more {
position: absolute;
top: 10px;
right: 10px;
}
}
</style>

View File

@ -0,0 +1,64 @@
<template>
<modal :hide-header="true">
<div class="project-entry-modal">
<text-form-with-help-text title="Tätigkeit" :value="localProjectEntry.activity"
@change="localProjectEntry.activity = $event"
data-cy="activity-input"
help-text="Was? Wie? Mittel?">
</text-form-with-help-text>
<text-form-with-help-text title="Reflexion" :value="localProjectEntry.reflection"
@change="localProjectEntry.reflection = $event"
data-cy="reflection-input"
help-text="Nachdenken über die eigene Tätigkeit und das eigene Handeln. Was ging gut? Was hatte ich für Schwierigkeiten? Was habe ich gelernt?">
</text-form-with-help-text>
<text-form-with-help-text title="Nächste Schritte" :value="localProjectEntry.nextSteps"
@change="localProjectEntry.nextSteps = $event"
data-cy="next-steps-input"
help-text="Wie geht es weiter? Wer macht was?">
</text-form-with-help-text>
<document-form :value="document" :index="0" @link-change-url="setDocumentUrl"></document-form>
</div>
<div slot="footer">
<a class="button button--primary" data-cy="modal-save-button" v-on:click="$emit('save', localProjectEntry)">Speichern</a>
<a class="button" v-on:click="$emit('hide')">Abbrechen</a>
</div>
</modal>
</template>
<script>
import Modal from '@/components/Modal';
import TextFormWithHelpText from '@/components/content-forms/TextFormWithHelpText';
import DocumentForm from '@/components/content-forms/DocumentForm';
export default {
props: ['project-entry'],
components: {
DocumentForm,
Modal,
TextFormWithHelpText
},
data() {
return {
localProjectEntry: Object.assign({}, {
...this.projectEntry
})
}
},
computed: {
document() {
return this.localProjectEntry.documentUrl > '' ? {
url: this.localProjectEntry.documentUrl
} : {};
}
},
methods: {
setDocumentUrl(url) {
this.localProjectEntry.documentUrl = url;
},
}
}
</script>

View File

@ -1,6 +1,6 @@
<template>
<div class="project-widget" :class="widgetClass">
<router-link :to="{name: 'project', params: {slug: slug}}" tag="div" class="project-widget__content">
<router-link :to="{name: 'project', params: {slug: slug}}" tag="div" class="project-widget__content" data-cy="project-link">
<h3 class="project-widget__title">{{title}}</h3>
<entry-count-widget :entry-count="entriesCount"></entry-count-widget>

View File

@ -1,17 +1,9 @@
<template>
<div class="room-entry">
<div class="room-entry__more" v-if="myEntry">
<a @click="showMenu = !showMenu" class="room-entry__more-link">
<ellipses class="room-entry__ellipses"></ellipses>
</a>
<widget-popover @hide-me="showMenu = false"
:id="id"
class="room-entry__popover"
v-if="showMenu">
<li class="popover-links__link"><a @click="deleteRoomEntry(id)">Eintrag löschen</a></li>
<li class="popover-links__link"><a @click="editRoomEntry(id)">Eintrag bearbeiten</a></li>
</widget-popover>
</div>
<more-options-widget class="room-entry__more" v-if="myEntry">
<li class="popover-links__link"><a @click="deleteRoomEntry(id)">Eintrag löschen</a></li>
<li class="popover-links__link"><a @click="editRoomEntry(id)">Eintrag bearbeiten</a></li>
</more-options-widget>
<router-link :to="{name: 'article', params: { slug: slug }}" tag="div" class="room-entry__router-link">
<div class="room-entry__header" v-if="image">
<img class="room-entry__image" :src="image" :alt="title">
@ -34,16 +26,14 @@
import ME_QUERY from '@/graphql/gql/meQuery.gql';
import UserWidget from '@/components/UserWidget.vue';
import Ellipses from '@/components/icons/Ellipses.vue';
import WidgetPopover from '@/components/rooms/WidgetPopover';
import MoreOptionsWidget from '@/components/MoreOptionsWidget';
export default {
props: ['title', 'author', 'contents', 'slug', 'id'],
components: {
MoreOptionsWidget,
UserWidget,
Ellipses,
WidgetPopover
},
methods: {
@ -110,12 +100,6 @@
}
}
},
data() {
return {
showMenu: false
}
}
}
</script>
@ -157,28 +141,7 @@
position: absolute;
top: 10px;
right: 10px;
display: flex;
justify-content: flex-end;
}
&__more-link {
background-color: rgba($color-white, 0.9);
width: 35px;
height: 15px;
border-radius: 15px;
display: flex;
justify-content: center;
}
&__ellipses {
width: 30px;
height: 30px;
fill: $color-darkgrey-1;
margin-top: -7px;
}
&__popover {
width: 180px;
}
}
</style>

View File

@ -47,7 +47,8 @@ const cache = new InMemoryCache({
assignment: (_, args, {getCacheKey}) => getCacheKey({__typename: 'AssignmentNode', id: args.id}),
objective: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ObjectiveNode', id: args.id}),
objectiveGroup: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ObjectiveGroupNode', id: args.id}),
module: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ModuleNode', id: args.id})
module: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ModuleNode', id: args.id}),
projectEntry: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ProjectEntryNode', id: args.id}),
}
}
});

View File

@ -4,5 +4,4 @@ fragment ProjectEntryParts on ProjectEntryNode {
reflection
nextSteps
documentUrl
created
}

View File

@ -3,6 +3,7 @@ mutation AddProjectEntryMutation($input: AddProjectEntryInput!) {
addProjectEntry(input: $input) {
projectEntry {
...ProjectEntryParts
created
}
errors
}

View File

@ -0,0 +1,9 @@
#import "../fragments/projectEntryParts.gql"
mutation UpdateProjectEntry($input: UpdateProjectEntryInput!){
updateProjectEntry(input: $input) {
projectEntry {
...ProjectEntryParts
}
errors
}
}

View File

@ -0,0 +1,6 @@
#import "./fragments/projectEntryParts.gql"
query ProjectEntryQuery($id: ID!) {
projectEntry(id: $id) {
...ProjectEntryParts
}
}

View File

@ -7,6 +7,7 @@ query ProjectQuery($id: ID, $slug: String){
edges {
node {
...ProjectEntryParts
created
}
}
}

View File

@ -13,7 +13,7 @@
</div>
<div class="project__content">
<add-project-entry v-if="isOwner" class="project__add-entry" :project="project.id"></add-project-entry>
<add-project-entry v-if="isOwner" class="project__add-entry" data-cy="add-project-entry" :project="project.id"></add-project-entry>
<project-entry v-bind="entry" v-for="(entry, index) in project.entries" :key="index"></project-entry>
</div>
</div>

View File

@ -20,6 +20,7 @@ export default new Vuex.Store({
objectiveGroupType: '',
currentObjectiveGroup: '',
parentProject: null,
currentProjectEntry: null,
imageUrl: '',
infographic: {
id: 0,
@ -41,6 +42,7 @@ export default new Vuex.Store({
scrollToAssignmentId: state => state.scrollToAssignmentId,
scrollToAssignmentReady: state => state.scrollToAssignmentReady,
scrollingToAssignment: state => state.scrollingToAssignment,
currentProjectEntry: state => state.currentProjectEntry,
},
actions: {
@ -61,6 +63,7 @@ export default new Vuex.Store({
commit('setObjectiveGroupType', '');
commit('setCurrentObjectiveGroup', '');
commit('setParentProject', null);
commit('setCurrentProjectEntry', null);
commit('setImageUrl', '');
commit('setInfographic', {
id: 0,
@ -110,6 +113,10 @@ export default new Vuex.Store({
commit('setParentProject', payload);
dispatch('showModal', 'new-project-entry-wizard');
},
editProjectEntry({commit, dispatch}, payload) {
commit('setCurrentProjectEntry', payload);
dispatch('showModal', 'edit-project-entry-wizard');
},
showFullscreenImage({commit, dispatch}, payload) {
commit('setImageUrl', payload);
dispatch('showModal', 'fullscreen-image');
@ -183,6 +190,9 @@ export default new Vuex.Store({
setParentProject(state, payload) {
state.parentProject = payload;
},
setCurrentProjectEntry(state, payload) {
state.currentProjectEntry = payload;
},
setImageUrl(state, payload) {
state.imageUrl = payload;
},

View File

@ -0,0 +1,38 @@
from django.core.management import BaseCommand
from books.models import Module
from portfolio.factories import ProjectFactory
from portfolio.models import ProjectEntry
from users.models import User
class Command(BaseCommand):
def handle(self, *args, **options):
self.stdout.write("Preparing projects")
user = User.objects.get(username='rahel.cueni')
self.stdout.write("Deleting all projects")
for project in user.projects.all():
project.delete()
self.stdout.write("Creating new project")
project = ProjectFactory(
title='Groot',
description='I am Groot',
student=user,
objectives='Be Groot\nBe awesome'
)
self.stdout.write("Creating project entries")
ProjectEntry.objects.create(
activity='Kill Thanos',
reflection='He sucks',
next_steps='Go for the head',
project=project
)
ProjectEntry.objects.create(
activity='Grow up again',
reflection='Being a teenager sucks',
next_steps='Grow',
project=project
)

View File

@ -10,27 +10,8 @@ from portfolio.schema import ProjectNode, ProjectEntryNode
from portfolio.serializers import ProjectSerializer, ProjectEntrySerializer
# class Mutation(relay.ClientIDMutation):
# class Meta:
# pass
#
# @classmethod
# def mutate_and_get_payload(cls, *args, **kwargs):
# data = kwargs.get(cls.meta.property)
# if data.get('id') is not None:
# project = get_object(cls.meta.serializer_class.model, data['id'])
# serializer = cls.meta.serializer_class(project, data=data)
# else:
# serializer = cls.meta.serializer_class(data=data)
# if serializer.is_valid():
# serializer.save()
# props = {
# cls.meta.property: serializer.instance,
# 'errors': None
# }
# return cls(**props)
#
# return cls(errors=['{}: {}'.format(key, value) for key, value in serializer.errors.items()])
def check_owner(user, project):
return user.id != project.student.id
class MutateProject(relay.ClientIDMutation):
errors = graphene.List(graphene.String)
@ -97,12 +78,13 @@ class MutateProjectEntry(relay.ClientIDMutation):
if data.get('project') is not None:
project = get_object(Project, data.get('project'))
data['project'] = project.id
if info.context.user.id != project.student.id:
return cls(project_entry=None, errors=['not allowed'])
if check_owner(info.context.user, project):
return cls(project_entry=None, errors=['not allowed'])
if data.get('id') is not None:
entity = get_object(ProjectEntry, data['id'])
if check_owner(info.context.user, entity.project):
return cls(project_entry=None, errors=['not allowed'])
serializer = ProjectEntrySerializer(entity, data=data, partial=True)
else:
serializer = ProjectEntrySerializer(data=data)