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'; 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 { export interface ContentBlock {
title: string; title: string;
contents: any[]; contents: any[];
id: string | undefined; id: string;
isAssignment: boolean; isAssignment: boolean;
type: types; 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 { export interface ActionOptions {

View File

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

View File

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

View File

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

View File

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