Merged in feature/MS-928-custom-solutions-form (pull request #149)

Feature/MS-928 custom solutions form
This commit is contained in:
Ramon Wenger 2024-04-29 15:26:43 +00:00
commit 722439ca9d
12 changed files with 276 additions and 224 deletions

View File

@ -1,3 +1,4 @@
<!-- Element used to display Blocks in a ContentForm -->
<template> <template>
<div class="content-element"> <div class="content-element">
<!-- Element Chooser if element has chooser type or no type --> <!-- Element Chooser if element has chooser type or no type -->
@ -38,65 +39,35 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
import { defineAsyncComponent } from 'vue';
import ContentFormSection from '@/components/content-block-form/ContentFormSection.vue'; import ContentFormSection from '@/components/content-block-form/ContentFormSection.vue';
import ContentElementActions from '@/components/content-block-form/ContentElementActions.vue'; import ContentElementActions from '@/components/content-block-form/ContentElementActions.vue';
import { defineAsyncComponent } from 'vue';
const TrashIcon = defineAsyncComponent(() => import('@/components/icons/TrashIcon.vue')); const TrashIcon = defineAsyncComponent(() => import('@/components/icons/TrashIcon.vue'));
const ContentBlockElementChooserWidget = defineAsyncComponent(() => const ContentBlockElementChooserWidget = defineAsyncComponent(
import('@/components/content-forms/ContentBlockElementChooserWidget.vue') () => import('@/components/content-forms/ContentBlockElementChooserWidget.vue')
); );
const LinkForm = defineAsyncComponent(() => const LinkForm = defineAsyncComponent(() => import('@/components/content-forms/LinkForm.vue'));
import('@/components/content-forms/LinkForm.vue') const VideoForm = defineAsyncComponent(() => import('@/components/content-forms/VideoForm.vue'));
); const ImageForm = defineAsyncComponent(() => import('@/components/content-forms/ImageForm.vue'));
const VideoForm = defineAsyncComponent(() => const DocumentForm = defineAsyncComponent(() => import('@/components/content-forms/DocumentForm.vue'));
import('@/components/content-forms/VideoForm.vue') const AssignmentForm = defineAsyncComponent(() => import('@/components/content-forms/AssignmentForm.vue'));
); const TextForm = defineAsyncComponent(
const ImageForm = defineAsyncComponent(() => () => import(/* webpackChunkName: "content-forms" */ '@/components/content-forms/TipTap.vue')
import('@/components/content-forms/ImageForm.vue')
);
const DocumentForm = defineAsyncComponent(() =>
import('@/components/content-forms/DocumentForm.vue')
);
const AssignmentForm = defineAsyncComponent(() =>
import('@/components/content-forms/AssignmentForm.vue')
);
const TextForm = defineAsyncComponent(() =>
import(/* webpackChunkName: "content-forms" */ '@/components/content-forms/TipTap.vue')
);
const SubtitleForm = defineAsyncComponent(() =>
import('@/components/content-forms/SubtitleForm.vue')
); );
const SubtitleForm = defineAsyncComponent(() => import('@/components/content-forms/SubtitleForm.vue'));
const SolutionForm = defineAsyncComponent(() => import('@/components/content-forms/SolutionForm.vue'));
// readonly blocks // readonly blocks
const Assignment = defineAsyncComponent(() => const Assignment = defineAsyncComponent(() => import('@/components/content-blocks/assignment/Assignment.vue'));
import('@/components/content-blocks/assignment/Assignment.vue') const SurveyBlock = defineAsyncComponent(() => import('@/components/content-blocks/SurveyBlock.vue'));
); const Solution = defineAsyncComponent(() => import('@/components/content-blocks/Solution.vue'));
const SurveyBlock = defineAsyncComponent(() => const ImageBlock = defineAsyncComponent(() => import('@/components/content-blocks/ImageBlock.vue'));
import('@/components/content-blocks/SurveyBlock.vue') const Instruction = defineAsyncComponent(() => import('@/components/content-blocks/Instruction.vue'));
); const ModuleRoomSlug = defineAsyncComponent(() => import('@/components/content-blocks/ModuleRoomSlug.vue'));
const Solution = defineAsyncComponent(() => const CmsDocumentBlock = defineAsyncComponent(() => import('@/components/content-blocks/CmsDocumentBlock.vue'));
import('@/components/content-blocks/Solution.vue') const ThinglinkBlock = defineAsyncComponent(() => import('@/components/content-blocks/ThinglinkBlock.vue'));
); const InfogramBlock = defineAsyncComponent(() => import('@/components/content-blocks/InfogramBlock.vue'));
const ImageBlock = defineAsyncComponent(() =>
import('@/components/content-blocks/ImageBlock.vue')
);
const Instruction = defineAsyncComponent(() =>
import('@/components/content-blocks/Instruction.vue')
);
const ModuleRoomSlug = defineAsyncComponent(() =>
import('@/components/content-blocks/ModuleRoomSlug.vue')
);
const CmsDocumentBlock = defineAsyncComponent(() =>
import('@/components/content-blocks/CmsDocumentBlock.vue')
);
const ThinglinkBlock = defineAsyncComponent(() =>
import('@/components/content-blocks/ThinglinkBlock.vue')
);
const InfogramBlock = defineAsyncComponent(() =>
import('@/components/content-blocks/InfogramBlock.vue')
);
const CHOOSER = 'content-block-element-chooser-widget'; const CHOOSER = 'content-block-element-chooser-widget';
export default { export default {
@ -141,8 +112,8 @@ export default {
InfogramBlock, InfogramBlock,
ThinglinkBlock, ThinglinkBlock,
Assignment, Assignment,
SolutionForm,
}, },
computed: { computed: {
actions() { actions() {
return { return {
@ -220,7 +191,7 @@ export default {
}; };
case 'solution': case 'solution':
return { return {
component: 'solution', component: 'solution-form',
title: 'Lösung', title: 'Lösung',
}; };
case 'image_block': case 'image_block':

View File

@ -29,15 +29,15 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits(['select']); const emit = defineEmits(['select']);
const defaultTitle = (type:string) => { const defaultTitle = (type: string) => {
return type.replace(/^\w/, (c :string) => c.toUpperCase()); return type.replace(/^\w/, (c: string) => c.toUpperCase());
} };
const defaultIcon = (type:string) => { const defaultIcon = (type: string) => {
return `${type}-icon`; return `${type}-icon`;
} };
const subclass =`chooser-element--${props.type}`; const subclass = `chooser-element--${props.type}`;
const cy = `choose-${props.type}-widget`; const cy = `choose-${props.type}-widget`;
const realTitle = computed(() => { const realTitle = computed(() => {
@ -55,7 +55,7 @@ export default {
components: { components: {
...formElementIcons, ...formElementIcons,
}, },
} };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -65,19 +65,20 @@ export default {
cursor: pointer; cursor: pointer;
border: 1px solid $color-silver; border: 1px solid $color-silver;
border-radius: 4px; border-radius: 4px;
height: 105px; height: 120px;
width: 105px; flex-direction: column;
flex: 0 0 calc(25% - $medium-spacing * 3 / 4);
box-sizing: border-box; box-sizing: border-box;
display: grid; display: flex;
grid-template-rows: 1fr 45px;
justify-content: center; justify-content: center;
justify-items: center; justify-items: center;
align-items: center; align-items: center;
align-content: center;
gap: $small-spacing;
&__icon { &__icon {
width: 40px; width: 40px;
height: 40px; height: 40px;
align-self: end;
} }
} }
</style> </style>

View File

@ -38,7 +38,7 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
import Checkbox from '@/components/ui/Checkbox.vue'; import Checkbox from '@/components/ui/Checkbox.vue';
import formElementIcons from '@/components/ui/form-element-icons'; import formElementIcons from '@/components/ui/form-element-icons';
@ -112,6 +112,12 @@ export default {
title: 'Dokument', title: 'Dokument',
show: hasDefaultFeatures, show: hasDefaultFeatures,
}, },
{
type: 'solution',
block: 'solution',
title: 'Lösung',
icon: 'tick-circle-icon',
},
], ],
}; };
}, },
@ -143,14 +149,11 @@ export default {
@import 'styles/helpers'; @import 'styles/helpers';
.content-block-element-chooser-widget { .content-block-element-chooser-widget {
display: -ms-grid; display: flex;
flex-wrap: wrap;
@supports (display: grid) { -ms-grid-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
display: grid; gap: $medium-spacing;
}
grid-template-columns: repeat(7, 1fr);
-ms-grid-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
grid-column-gap: 0;
font-family: $sans-serif-font-family; font-family: $sans-serif-font-family;
text-align: center; text-align: center;
position: relative; position: relative;

View File

@ -3,137 +3,24 @@
class="document-form" class="document-form"
ref="documentform" ref="documentform"
> >
<div <document-input
v-if="!value.url" :url="value.url"
ref="uploadcare-panel" @update="updateUrl"
/> />
<div
class="document-form__spinner"
v-if="loading"
>
<loading-icon class="document-form__loading-icon" />
</div>
<div
class="document-form__uploaded"
v-if="value.url"
>
<document-icon class="document-form__icon" />
<a
:href="previewUrl"
class="document-form__link"
target="_blank"
>{{ previewLink }}</a
>
</div>
</div> </div>
</template> </template>
<script> <script setup lang="ts">
import { uploadcare } from '@/helpers/uploadcare'; import DocumentInput from '@/components/content-forms/DocumentInput.vue';
import LoadingIcon from '@/components/icons/LoadingIcon.vue'; export interface Props {
import { defineAsyncComponent } from 'vue'; value: any;
index: number;
}
const DocumentIcon = defineAsyncComponent(() => const props = defineProps<Props>();
import('@/components/icons/DocumentIcon.vue') const emit = defineEmits(['change-url']);
);
export default { const updateUrl = (url: string) => {
props: ['value', 'index'], emit('change-url', url, props.index);
components: {
LoadingIcon,
DocumentIcon,
},
data() {
return {
loading: false,
};
},
computed: {
previewUrl() {
if (this.value && this.value.url) {
return this.value.url;
}
return null;
},
previewLink() {
if (this.value && this.value.url) {
const parts = this.value.url.split('/');
return parts[parts.length - 1];
}
return '';
},
},
mounted() {
uploadcare(
this,
(url) => {
this.$emit('change-url', url, this.index);
this.loading = false;
},
() => {
this.loading = true;
}
);
},
}; };
</script> </script>
<style scoped lang="scss">
@import 'styles/helpers';
.document-form {
&__uploaded {
display: flex;
align-items: center;
}
&__link {
text-decoration: underline;
}
&__spinner {
width: 100%;
height: 150px;
display: flex;
align-items: center;
justify-content: center;
}
&__loading-icon {
@include spin;
fill: $color-silver-dark;
}
&__icon {
width: 30px;
height: 30px;
margin-right: $small-spacing;
}
&__file-input {
width: 0.1px;
height: 0.1px;
overflow: hidden;
opacity: 0;
position: absolute;
z-index: -1;
& + label {
cursor: pointer;
background-color: $color-silver-light;
height: 150px;
display: flex;
width: 100%;
justify-content: center;
align-items: center;
font-family: $sans-serif-font-family;
font-weight: $font-weight-regular;
text-decoration: underline;
}
}
}
</style>

View File

@ -0,0 +1,141 @@
<template>
<div
class="document-input"
ref="documentform"
>
<div
v-if="!url"
ref="uploadcarePanel"
/>
<div
class="document-input__spinner"
v-if="loading"
>
<loading-icon class="document-input__loading-icon" />
</div>
<div
class="document-input__uploaded"
v-if="url"
>
<document-icon class="document-input__icon" />
<a
:href="previewUrl"
class="document-input__link"
target="_blank"
>{{ previewLink }}</a
>
</div>
</div>
</template>
<script lang="ts" setup>
import LoadingIcon from '@/components/icons/LoadingIcon.vue';
import { uploadcare } from '@/helpers/uploadcare';
import { computed, onMounted, ref } from 'vue';
import { defineAsyncComponent } from 'vue';
const DocumentIcon = defineAsyncComponent(() => import('@/components/icons/DocumentIcon.vue'));
const loading = ref(false);
const documentform = ref(null);
const uploadcarePanel = ref(null);
export interface Props {
url: string;
}
const props = defineProps<Props>();
const previewUrl = computed(() => {
return props.url ? props.url : '';
});
const previewLink = computed(() => {
const url = props.url;
if (url > '') {
const parts = url.split('/');
return parts[parts.length - 1];
}
return '';
});
const emit = defineEmits(['update']);
onMounted(() => {
uploadcare(
{
$refs: {
'uploadcare-panel': uploadcarePanel.value,
documentform: documentform.value,
},
},
(url: string) => {
emit('update', url);
loading.value = false;
},
() => {
loading.value = true;
}
);
});
</script>
<style scoped lang="postcss">
/* todo: do this with a mixin, but in postcss (we have one in scss) */
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
.document-input {
&__uploaded {
display: flex;
align-items: center;
}
&__link {
text-decoration: underline;
}
&__spinner {
width: 100%;
height: 150px;
display: flex;
align-items: center;
justify-content: center;
}
&__loading-icon {
/* todo: make this work with postcss
// @include spin;
*/
animation: spin 2.5s linear infinite;
fill: var(--color-silver-dark);
}
&__icon {
width: 30px;
height: 30px;
margin-right: var(--small-spacing);
}
&__file-input {
/* this element should not be visible for the user, but the width can't be 0, otherwise it does not exist in the
* DOM. With this hack, the element still works */
width: 0.1px;
height: 0.1px;
overflow: hidden;
opacity: 0;
position: absolute;
z-index: -1;
& + label {
cursor: pointer;
background-color: var(--color-silver-light);
height: 150px;
display: flex;
width: 100%;
justify-content: center;
align-items: center;
font-family: var(--sans-serif-font-family);
font-weight: var(--regular-font-weight);
text-decoration: underline;
}
}
}
</style>

View File

@ -28,7 +28,7 @@ export default {
.link-form { .link-form {
display: grid; display: grid;
grid-auto-rows: auto; grid-auto-rows: auto;
grid-row-gap: 11px; grid-row-gap: $small-spacing;
&__text, &__text,
&__url { &__url {

View File

@ -0,0 +1,37 @@
<template>
<div class="solution-form">
<textarea
placeholder="Lösung erfassen"
class="skillbox-textarea"
:value="text"
@input="$emit('change-text', $event.target.value, index)"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
export interface Props {
value: any;
index: number;
}
const props = withDefaults(defineProps<Props>(), {
value: null,
index: -1,
});
defineEmits(['change-text']);
const text = computed(() => {
// todo: refactor this and use the helper, just copied it from TextForm.vue
return props.value.text ? props.value.text.replace(/<br(\/)?>/, '\n').replace(/(<([^>]+)>)/gi, '') : '';
});
</script>
<style lang="postcss" scoped>
.solution-form {
display: flex;
flex-direction: column;
gap: var(--small-spacing);
}
</style>

View File

@ -3,29 +3,16 @@ const LinkIcon = defineAsyncComponent(() => import('@/components/icons/LinkIcon.
const VideoIcon = defineAsyncComponent(() => import('@/components/icons/VideoIcon.vue')); const VideoIcon = defineAsyncComponent(() => import('@/components/icons/VideoIcon.vue'));
const ImageIcon = defineAsyncComponent(() => import('@/components/icons/ImageIcon.vue')); const ImageIcon = defineAsyncComponent(() => import('@/components/icons/ImageIcon.vue'));
const TextIcon = defineAsyncComponent(() => import('@/components/icons/TextIcon.vue')); const TextIcon = defineAsyncComponent(() => import('@/components/icons/TextIcon.vue'));
const SpeechBubbleIcon = defineAsyncComponent(() => const SpeechBubbleIcon = defineAsyncComponent(() => import('@/components/icons/SpeechBubbleIcon.vue'));
import('@/components/icons/SpeechBubbleIcon.vue') const DocumentIcon = defineAsyncComponent(() => import('@/components/icons/DocumentIcon.vue'));
);
const DocumentIcon = defineAsyncComponent(() =>
import('@/components/icons/DocumentIcon.vue')
);
const TitleIcon = defineAsyncComponent(() => import('@/components/icons/TitleIcon.vue')); const TitleIcon = defineAsyncComponent(() => import('@/components/icons/TitleIcon.vue'));
const DocumentWithLinesIcon = defineAsyncComponent(() => const DocumentWithLinesIcon = defineAsyncComponent(() => import('@/components/icons/DocumentWithLinesIcon.vue'));
import('@/components/icons/DocumentWithLinesIcon.vue') const ArrowThinBottom = defineAsyncComponent(() => import('@/components/icons/ArrowThinBottom.vue'));
); const ArrowThinDown = defineAsyncComponent(() => import('@/components/icons/ArrowThinDown.vue'));
const ArrowThinBottom = defineAsyncComponent(() => const ArrowThinTop = defineAsyncComponent(() => import('@/components/icons/ArrowThinTop.vue'));
import('@/components/icons/ArrowThinBottom.vue') const ArrowThinUp = defineAsyncComponent(() => import('@/components/icons/ArrowThinUp.vue'));
);
const ArrowThinDown = defineAsyncComponent(() =>
import('@/components/icons/ArrowThinDown.vue')
);
const ArrowThinTop = defineAsyncComponent(() =>
import('@/components/icons/ArrowThinTop.vue')
);
const ArrowThinUp = defineAsyncComponent(() =>
import('@/components/icons/ArrowThinUp.vue')
);
const TrashIcon = defineAsyncComponent(() => import('@/components/icons/TrashIcon.vue')); const TrashIcon = defineAsyncComponent(() => import('@/components/icons/TrashIcon.vue'));
const TickCircleIcon = defineAsyncComponent(() => import('@/components/icons/TickCircleIcon.vue'));
/* /*
for icons with a single word, leave the *-icon name, to prevent conflicts for icons with a single word, leave the *-icon name, to prevent conflicts
@ -45,4 +32,5 @@ export default {
ArrowThinTop, ArrowThinTop,
ArrowThinUp, ArrowThinUp,
TrashIcon, TrashIcon,
TickCircleIcon,
}; };

View File

@ -16,7 +16,6 @@ const isSafe = (attr) => {
const removeAttributes = (node) => { const removeAttributes = (node) => {
let attributes = [...node.attributes]; let attributes = [...node.attributes];
console.log(attributes);
for (const attr of attributes) { for (const attr of attributes) {
if (isSafe(attr)) { if (isSafe(attr)) {
continue; continue;

View File

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

View File

@ -6,8 +6,11 @@ import bleach
from api.utils import get_object from api.utils import get_object
from assignments.models import Assignment from assignments.models import Assignment
from books.models import ContentBlock from books.models import ContentBlock
from core.logger import get_logger
from wagtail.blocks import StreamValue from wagtail.blocks import StreamValue
logger = get_logger(__name__)
class AssignmentParameterException(Exception): class AssignmentParameterException(Exception):
pass pass
@ -46,6 +49,7 @@ ALLOWED_BLOCKS = (
"document_block", "document_block",
"content_list_item", "content_list_item",
"subtitle", "subtitle",
"solution",
"readonly", "readonly",
) )
@ -75,6 +79,7 @@ def handle_content_block(
# todo: add all the content blocks # todo: add all the content blocks
# todo: sanitize user inputs! # todo: sanitize user inputs!
if content["type"] not in allowed_blocks: if content["type"] not in allowed_blocks:
logger.error(f"{content['type']} not in allowed blocks")
return return
id = content.get("id") id = content.get("id")
@ -91,6 +96,7 @@ def handle_content_block(
id=id, id=id,
value=value, value=value,
) )
elif content["type"] == "assignment": elif content["type"] == "assignment":
if module is None: if module is None:
raise AssignmentParameterException( raise AssignmentParameterException(
@ -136,6 +142,12 @@ def handle_content_block(
content_type = "document_block" content_type = "document_block"
value = {"url": bleach.clean(content["value"]["url"])} value = {"url": bleach.clean(content["value"]["url"])}
return get_content_dict(content_type=content_type, id=id, value=value) return get_content_dict(content_type=content_type, id=id, value=value)
elif content["type"] == "solution":
content_type = "solution"
value = {
"text": handle_text(bleach.clean(content["value"]["text"], strip=True)),
}
return get_content_dict(content_type=content_type, id=id, value=value)
elif content["type"] == "subtitle": elif content["type"] == "subtitle":
content_type = "subtitle" content_type = "subtitle"
value = {"text": bleach.clean(content["value"]["text"])} value = {"text": bleach.clean(content["value"]["text"])}

View File

@ -273,6 +273,18 @@ class TestCreateCustomContentBlock:
assert subtitle_block.get("id") is not None assert subtitle_block.get("id") is not None
assert subtitle_block.get("value").get("text") == value.get("text") assert subtitle_block.get("value").get("text") == value.get("text")
def test_add_solution(self, teacher, get_client):
content_type = "solution"
value = {"text": "some solution"}
client = get_client(teacher)
input = create_block_input(content_type=content_type, value=value)
new_content_block = self._add_content_block(client=client, input=input)
subtitle_block = new_content_block.contents.raw_data[0]
assert subtitle_block.get("id") is not None
assert subtitle_block.get("value").get("text") == "<p>{}</p>".format(
value.get("text")
)
# def test_add_readonly(self): # def test_add_readonly(self):
# content_type = "readonly" # content_type = "readonly"
# value = {"text": "some subtitle"} # value = {"text": "some subtitle"}