Refactor content block component, me mixin, add scrolling

This commit is contained in:
Ramon Wenger 2023-02-08 19:14:18 +01:00
parent 93be4fc972
commit 07b4701529
6 changed files with 384 additions and 269 deletions

View File

@ -2,12 +2,32 @@ export type numberOrUndefined = number | undefined;
type types = 'task' | 'normal' | 'base_communication' | 'base_society' | 'base_interdisciplinary';
export interface InstrumentCategory {
id: string;
name: string;
background: string;
foreground: string;
types: any[];
}
export interface ContentBlock {
title: string;
contents: any[];
id: string | undefined;
id: string;
isAssignment: boolean;
type: types;
notes: any[];
bookmarks: any[];
indent?: boolean;
instrumentCategory: InstrumentCategory;
mine: boolean;
userCreated: boolean;
hiddenFor: any[];
visibleFor: any[];
root: string;
titleHiddenFor: any[];
descriptionHiddenFor: any[];
hidden: boolean;
}
export interface ActionOptions {

View File

@ -2,6 +2,7 @@
<div
:class="{ 'hideable-element--greyed-out': isHidden }"
class="content-block__container hideable-element content-list__parent"
ref="wrapper"
>
<div
:class="specialClass"
@ -26,7 +27,7 @@
<popover-link
data-cy="duplicate-content-block-link"
text="Duplizieren"
@link-action="duplicateContentBlock(contentBlock)"
@link-action="duplicateContentBlock()"
/>
</li>
<li
@ -36,7 +37,7 @@
<popover-link
data-cy="delete-content-block-link"
text="Löschen"
@link-action="deleteContentBlock(contentBlock)"
@link-action="deleteContentBlock()"
/>
</li>
@ -46,7 +47,7 @@
>
<popover-link
text="Bearbeiten"
@link-action="editContentBlock(contentBlock)"
@link-action="editContentBlock()"
/>
</li>
</more-options-widget>
@ -93,99 +94,213 @@
</div>
</template>
<script setup>
import { defineAsyncComponent } from 'vue';
<script setup lang="ts">
import { defineAsyncComponent, inject, onMounted, ref, computed } from 'vue';
import { useMutation } from '@vue/apollo-composable';
import AddContentButton from '@/components/AddContentButton.vue';
import MoreOptionsWidget from '@/components/MoreOptionsWidget.vue';
import UserWidget from '@/components/UserWidget.vue';
import VisibilityAction from '@/components/visibility/VisibilityAction.vue';
import PopoverLink from '@/components/ui/PopoverLink.vue';
import CopyLink from '@/components/CopyLink.vue';
import { useRoute, useRouter } from 'vue-router';
import { hidden } from '@/helpers/visibility';
import { insertAtIndex, removeAtIndex } from '@/graphql/immutable-operations';
import { instrumentCategory } from '@/helpers/instrumentType';
import { CONTENT_TYPE } from '@/consts/types';
import { EDIT_CONTENT_BLOCK_PAGE } from '@/router/module.names';
import { getMe } from '@/mixins/me';
const ContentComponent = defineAsyncComponent(() =>
import(/* webpackChunkName: "content-components" */ '@/components/content-blocks/ContentComponent.vue')
);
</script>
<script>
import DUPLICATE_CONTENT_BLOCK_MUTATION from '@/graphql/gql/mutations/duplicateContentBlock.gql';
import CHAPTER_QUERY from '@/graphql/gql/queries/chapterQuery.gql';
import DELETE_CONTENT_BLOCK_MUTATION from '@/graphql/gql/mutations/deleteContentBlock.gql';
import DUPLICATE_CONTENT_BLOCK_MUTATION from '@/graphql/gql/mutations/duplicateContentBlock.gql';
import me from '@/mixins/me';
import type { ContentBlock } from '@/@types';
import type { Modal } from '@/plugins/modal.types';
import { hidden } from '@/helpers/visibility';
import { CONTENT_TYPE } from '@/consts/types';
import { insertAtIndex, removeAtIndex } from '@/graphql/immutable-operations';
import { EDIT_CONTENT_BLOCK_PAGE } from '@/router/module.names';
import { instrumentCategory } from '@/helpers/instrumentType';
export interface Props {
contentBlock: ContentBlock;
parent?: any;
editMode: boolean;
}
export default {
name: 'ContentBlock',
props: {
contentBlock: {
type: Object,
default: () => ({}),
},
parent: {
type: Object,
default: () => ({}),
},
editMode: {
type: Boolean,
default: true,
const ContentComponent = defineAsyncComponent(
() => import(/* webpackChunkName: "content-components" */ '@/components/content-blocks/ContentComponent.vue')
);
const { me, schoolClass } = getMe();
const wrapper = ref<HTMLElement | null>(null);
const route = useRoute();
const router = useRouter();
const modal = inject('modal') as Modal;
const props = withDefaults(defineProps<Props>(), {
editMode: false,
});
const { mutate: duplicateContentBlock } = useMutation(DUPLICATE_CONTENT_BLOCK_MUTATION, () => ({
variables: {
input: {
id: props.contentBlock.id,
},
},
update: (
store,
{
data: {
duplicateContentBlock: { contentBlock },
},
}
) => {
const id = props.parent.id;
if (contentBlock) {
const query = CHAPTER_QUERY;
const variables = {
id,
};
const { chapter }: any = store.readQuery({ query, variables });
const index = chapter.contentBlocks.findIndex((contentBlock: ContentBlock) => contentBlock.id === id);
const contentBlocks = insertAtIndex(chapter.contentBlocks, index, contentBlock);
const data = {
chapter: {
...chapter,
contentBlocks,
},
};
store.writeQuery({ query, variables, data });
}
},
}));
mixins: [me],
const { mutate: doDeleteContentBlock } = useMutation(DELETE_CONTENT_BLOCK_MUTATION, () => ({
variables: {
input: {
id: props.contentBlock.id,
},
},
update: (
store,
{
data: {
deleteContentBlock: { success },
},
}
) => {
if (success) {
const query = CHAPTER_QUERY;
const variables = {
id: props.parent.id,
};
const { chapter }: any = store.readQuery({ query, variables });
const index = chapter.contentBlocks.findIndex(
(contentBlock: ContentBlock) => contentBlock.id === props.parent.id
);
const contentBlocks = removeAtIndex(chapter.contentBlocks, index);
const data = {
chapter: {
...chapter,
contentBlocks,
},
};
store.writeQuery({ query, variables, data });
}
},
}));
computed: {
canEditModule() {
return !this.contentBlock.indent && this.editMode;
const top = ref(0);
onMounted(() => {
const element = wrapper.value;
if (element !== null) {
if (route.hash === `#${props.contentBlock.id}`) {
setTimeout(() => {
const rect = element.getBoundingClientRect();
window.scrollTo({ top: rect.y, behavior: 'smooth' });
top.value = rect.y;
console.log(rect.y);
console.log(rect);
}, 0);
console.log(document.readyState);
}
}
});
// methods
const createContentListOrBlocks = (contentList: any[]) => {
return [
{
type: 'content_list',
contents: contentList,
id: contentList[0].id,
},
specialClass() {
return `content-block--${this.contentBlock.type.toLowerCase()}`;
];
};
const editContentBlock = () => {
const route = {
name: EDIT_CONTENT_BLOCK_PAGE,
params: {
id: props.contentBlock.id,
},
isInstrumentBlock() {
return !!this.contentBlock.instrumentCategory;
},
// todo: use dynamic css class with v-bind once we're on Vue 3: https://vuejs.org/api/sfc-css-features.html#v-bind-in-css
instrumentStyle() {
if (this.isInstrumentBlock) {
return {
backgroundColor: this.contentBlock.instrumentCategory.background,
};
}
return {};
},
instrumentLabel() {
const contentType = this.contentBlock.type.toLowerCase();
if (contentType.startsWith('base')) {
// all legacy instruments start with `base`
return instrumentCategory(contentType);
}
if (this.isInstrumentBlock) {
return instrumentCategory(this.contentBlock.instrumentCategory.name);
}
return '';
},
// todo: use dynamic css class with v-bind once we're on Vue 3: https://vuejs.org/api/sfc-css-features.html#v-bind-in-css
instrumentLabelStyle() {
if (this.isInstrumentBlock) {
return {
color: this.contentBlock.instrumentCategory.foreground,
};
}
return {};
},
canEditContentBlock() {
return this.isMine && !this.contentBlock.indent;
},
isMine() {
return this.contentBlock.mine;
},
contentBlocksWithContentLists() {
/*
};
router.push(route);
};
const deleteContentBlock = () => {
modal
.open('confirm')
.then(() => {
doDeleteContentBlock();
})
.catch();
};
// computed properties
const canEditModule = computed(() => {
return !props.contentBlock.indent && props.editMode;
});
const specialClass = computed(() => {
return `content-block--${props.contentBlock.type.toLowerCase()}`;
});
const isInstrumentBlock = computed(() => {
return !!props.contentBlock.instrumentCategory;
});
// todo: use dynamic css class with v-bind once we're on Vue 3: https://vuejs.org/api/sfc-css-features.html#v-bind-in-css
const instrumentStyle = computed(() => {
if (isInstrumentBlock.value) {
return {
backgroundColor: props.contentBlock.instrumentCategory.background,
};
}
return {};
});
const instrumentLabel = computed(() => {
const contentType = props.contentBlock.type.toLowerCase();
if (contentType.startsWith('base')) {
// all legacy instruments start with `base`
return instrumentCategory(contentType);
}
if (isInstrumentBlock.value) {
return instrumentCategory(props.contentBlock.instrumentCategory.name);
}
return '';
});
// todo: use dynamic css class with v-bind once we're on Vue 3: https://vuejs.org/api/sfc-css-features.html#v-bind-in-css
const instrumentLabelStyle = computed(() => {
if (isInstrumentBlock.value) {
return {
color: props.contentBlock.instrumentCategory.foreground,
};
}
return {};
});
const isMine = computed(() => {
return props.contentBlock.mine;
});
const contentBlocksWithContentLists = computed(() => {
/*
collects all content_list_items in content_lists:
{
text_block,
@ -199,146 +314,43 @@ export default {
text_block
}
*/
let contentList = [];
let newContent = this.contentBlock.contents.reduce((newContents, content, index) => {
// collect content_list_items
if (content.type === 'content_list_item') {
contentList = [...contentList, content];
if (index === this.contentBlock.contents.length - 1) {
// content is last element of contents array
let updatedContent = [...newContents, ...this.createContentListOrBlocks(contentList)];
return updatedContent;
}
return newContents;
} else {
// handle all other items and reset current content_list if necessary
if (contentList.length !== 0) {
newContents = [...newContents, ...this.createContentListOrBlocks(contentList), content];
contentList = [];
return newContents;
} else {
return [...newContents, content];
}
}
}, []);
return Object.assign({}, this.contentBlock, {
contents: newContent,
});
},
isHidden() {
return hidden({
block: this.contentBlock,
schoolClass: this.schoolClass,
type: CONTENT_TYPE,
});
},
root() {
// we need the root content block id, not the generated content block if inside a content list block
return this.contentBlock.root ? this.contentBlock.root : this.contentBlock.id;
},
},
methods: {
duplicateContentBlock({ id }) {
const parent = this.parent;
this.$apollo.mutate({
mutation: DUPLICATE_CONTENT_BLOCK_MUTATION,
variables: {
input: {
id,
},
},
update(
store,
{
data: {
duplicateContentBlock: { contentBlock },
},
}
) {
if (contentBlock) {
const query = CHAPTER_QUERY;
const variables = {
id: parent.id,
};
const { chapter } = store.readQuery({ query, variables });
const index = chapter.contentBlocks.findIndex((contentBlock) => contentBlock.id === id);
const contentBlocks = insertAtIndex(chapter.contentBlocks, index, contentBlock);
const data = {
chapter: {
...chapter,
contentBlocks,
},
};
store.writeQuery({ query, variables, data });
}
},
});
},
editContentBlock(contentBlock) {
const route = {
name: EDIT_CONTENT_BLOCK_PAGE,
params: {
id: contentBlock.id,
},
};
this.$router.push(route);
},
deleteContentBlock(contentBlock) {
this.$modal
.open('confirm')
.then(() => {
this.doDeleteContentBlock(contentBlock);
})
.catch();
},
doDeleteContentBlock(contentBlock) {
const parent = this.parent;
const id = contentBlock.id;
this.$apollo.mutate({
mutation: DELETE_CONTENT_BLOCK_MUTATION,
variables: {
input: {
id,
},
},
update(
store,
{
data: {
deleteContentBlock: { success },
},
}
) {
if (success) {
const query = CHAPTER_QUERY;
const variables = {
id: parent.id,
};
const { chapter } = store.readQuery({ query, variables });
const index = chapter.contentBlocks.findIndex((contentBlock) => contentBlock.id === id);
const contentBlocks = removeAtIndex(chapter.contentBlocks, index);
const data = {
chapter: {
...chapter,
contentBlocks,
},
};
store.writeQuery({ query, variables, data });
}
},
});
},
createContentListOrBlocks(contentList) {
return [
{
type: 'content_list',
contents: contentList,
id: contentList[0].id,
},
];
},
},
};
let contentList: any[] = [];
let newContent = props.contentBlock.contents.reduce((newContents, content, index) => {
// collect content_list_items
if (content.type === 'content_list_item') {
contentList = [...contentList, content];
if (index === props.contentBlock.contents.length - 1) {
// content is last element of contents array
let updatedContent = [...newContents, ...createContentListOrBlocks(contentList)];
return updatedContent;
}
return newContents;
} else {
// handle all other items and reset current content_list if necessary
if (contentList.length !== 0) {
newContents = [...newContents, ...createContentListOrBlocks(contentList), content];
contentList = [];
return newContents;
} else {
return [...newContents, content];
}
}
}, []);
return Object.assign({}, props.contentBlock, {
contents: newContent,
});
});
const isHidden = computed(() => {
return hidden({
block: props.contentBlock,
schoolClass: schoolClass,
type: CONTENT_TYPE,
});
});
const root = computed(() => {
// we need the root content block id, not the generated content block if inside a content list block
return props.contentBlock.root ? props.contentBlock.root : props.contentBlock.id;
});
</script>
<style scoped lang="scss">

View File

@ -1,14 +1,16 @@
<template>
<div
class="copy-link"
:class="{ 'copy-link--active': show }"
@click="copyLink"
>
Link kopiert <link-icon class="copy-link__icon" />
<Transition name="fade"> <span v-if="show">Link kopiert </span> </Transition>
<link-icon class="copy-link__icon" />
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent, computed } from 'vue';
import { defineAsyncComponent, computed, ref } from 'vue';
const LinkIcon = defineAsyncComponent(() => import(/* webpackChunkName: "icons" */ '@/components/icons/LinkIcon.vue'));
@ -20,8 +22,10 @@ const directLink = computed(() => {
const { host, protocol } = window.location;
return `${protocol}//${host}/content/${props.id}`;
});
const show = ref(false);
const copyLink = () => {
show.value = true;
navigator.clipboard.writeText(directLink.value).then(
() => {
console.log('yay!', directLink.value);
@ -30,13 +34,15 @@ const copyLink = () => {
console.log('nay!');
}
);
setTimeout(() => {
show.value = false;
}, 3000);
};
</script>
<style lang="scss">
@import '~styles/helpers';
.copy-link {
background-color: $color-brand-light;
display: inline-flex;
align-items: center;
gap: $small-spacing;
@ -44,10 +50,34 @@ const copyLink = () => {
border-radius: 5px;
cursor: pointer;
@include regular-text;
transition: background-color 0.25s ease;
&--active {
background-color: $color-brand-light;
}
&__icon {
width: 30px;
height: 30px;
fill: $color-silver-dark;
&:hover {
fill: $color-charcoal-dark;
}
}
&--active &__icon {
fill: $color-charcoal-dark;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -1,51 +1,99 @@
import ME_QUERY from '@/graphql/gql/queries/meQuery.gql';
import { computed } from 'vue';
import { useQuery } from '@vue/apollo-composable';
export default {
data() {
const defaultMe = {
me: {
selectedClass: {
id: '',
},
permissions: [],
schoolClasses: [],
isTeacher: false,
team: null,
},
showPopover: false,
};
const getTopicRoute = (me) => {
if (me.lastTopic && me.lastTopic.slug) {
return {
me: {
selectedClass: {
id: '',
},
permissions: [],
schoolClasses: [],
isTeacher: false,
team: null,
name: 'topic',
params: {
topicSlug: me.lastTopic.slug,
},
showPopover: false,
};
}
return '/book/topic/berufliche-grundbildung';
};
const getSelectedClass = (me) => {
return me.selectedClass;
};
const getCanManageContent = (me) => {
return me.isTeacher;
};
const getIsReadOnly = (me) => {
return me.readOnly || me.selectedClass.readOnly;
};
const getCurrentClassName = (me) => {
let currentClass = me.schoolClasses.find((schoolClass) => {
return schoolClass.id === me.selectedClass.id;
});
return currentClass ? currentClass.name : me.schoolClasses.length ? me.schoolClasses[0].name : '';
};
const options = {
fetchPolicy: 'cache-first',
};
const getMe = () => {
const { result } = useQuery(ME_QUERY, null, options);
const me = computed(() => result.value?.me, defaultMe.me);
const topicRoute = computed(() => {
return getTopicRoute(me.value);
});
const schoolClass = computed(() => {
return getSelectedClass(me.value);
});
const canManageContent = computed(() => {
return getCanManageContent(me.value);
});
const isReadOnly = computed(() => {
return getIsReadOnly(me.value);
});
const currentClassName = computed(() => {
return getCurrentClassName(me.value);
});
return { me, topicRoute, schoolClass, canManageContent, isReadOnly, currentClassName };
};
const meMixin = {
data() {
return defaultMe;
},
computed: {
topicRoute() {
if (this.$data.me.lastTopic && this.$data.me.lastTopic.slug) {
return {
name: 'topic',
params: {
topicSlug: this.$data.me.lastTopic.slug,
},
};
}
return '/book/topic/berufliche-grundbildung';
return getTopicRoute(this.$data.me);
},
schoolClass() {
return this.$data.me.selectedClass;
return getSelectedClass(this.$data.me);
},
canManageContent() {
return this.$data.me.isTeacher;
return getCanManageContent(this.$data.me);
},
isReadOnly() {
return this.$data.me.readOnly || this.$data.me.selectedClass.readOnly;
return getIsReadOnly(this.$data.me);
},
currentClassName() {
let currentClass = this.$data.me.schoolClasses.find((schoolClass) => {
return schoolClass.id === this.$data.me.selectedClass.id;
});
return currentClass
? currentClass.name
: this.$data.me.schoolClasses.length
? this.$data.me.schoolClasses[0].name
: '';
return getCurrentClassName(this.$data.me);
},
},
@ -56,7 +104,9 @@ export default {
// todo: refactor
return this.$getRidOfEdges(data).me;
},
fetchPolicy: 'cache-first',
...options,
},
},
};
export { getMe, meMixin as default };

View File

@ -1,6 +1,7 @@
// adapted from
// https://stackoverflow.com/questions/41791193/vuejs-reactive-binding-for-a-plugin-how-to/41801107#41801107
import { reactive, App } from 'vue';
import { Modal } from './modal.types';
class ModalStore {
data: any;
@ -17,18 +18,7 @@ class ModalStore {
}
}
interface Modal {
state: any;
component: string;
payload?: any;
confirm: (res: any) => void;
open: (component: string, payload?: any) => Promise<(resolve: () => any, reject: () => any) => void>;
cancel: () => void;
_resolve: (r?: any) => any;
_reject: (r?: any) => any;
}
declare module '@vue/runtime-core' {
declare module 'vue' {
interface ComponentCustomProperties {
$modal: Modal;
}
@ -44,7 +34,7 @@ const ModalPlugin = {
store.state.payload = {};
};
app.config.globalProperties.$modal = {
const modal = {
state: store.state,
component: store.state.component,
payload: store.state.payload,
@ -64,9 +54,12 @@ const ModalPlugin = {
reset();
this._reject();
},
_resolve: () => {},
_reject: () => {},
_resolve: (_: any) => { },
_reject: () => { },
};
app.config.globalProperties.$modal = modal;
app.provide('modal', modal);
},
};

View File

@ -0,0 +1,10 @@
export interface Modal {
state: any;
component: string;
payload?: any;
confirm: (res: any) => void;
open: (component: string, payload?: any) => Promise<(resolve: () => any, reject: () => any) => void>;
cancel: () => void;
_resolve: (r?: any) => any;
_reject: (r?: any) => any;
}