Add content element action menu

This commit is contained in:
Ramon Wenger 2022-02-15 17:21:41 +01:00
parent bde635b21c
commit 13a5ea9534
15 changed files with 415 additions and 82 deletions

View File

@ -9,8 +9,6 @@
import PlusIcon from '@/components/icons/PlusIcon';
export default {
components: { PlusIcon }
//
};
</script>
@ -19,8 +17,10 @@
$color: $color-silver-dark;
.add-content-link {
display: flex;
display: inline-flex;
align-items: center;
justify-content: flex-start;
cursor: pointer;
&__icon {
width: 20px;

View File

@ -29,7 +29,6 @@
<!-- Add content at top of content block -->
<add-content-link
class="content-block-form__segment"
@click="addBlock(-1)"
/>
<!-- Loop for outer contents layer -->
@ -39,31 +38,45 @@
:key="block.id"
>
<!-- If the block is a content list -->
<ol
class="content-list__item content-block-form__segment"
data-cy="content-list-item"
<div
class="content-block-form__segment"
v-if="block.type === 'content_list_item'"
>
<li
class="content-block-form__segment"
v-for="(content, index) in block.contents"
:key="content.id"
<content-element-actions
class="content-block-form__actions"
@remove="remove(outer)"
@move-up="up(outer)"
@move-down="down(outer)"
@move-top="top(outer)"
@move-bottom="bottom(outer)"
/>
<ol
class="content-list__item"
data-cy="content-list-item"
>
<content-element
:element="content"
<li
class="content-block-form__segment"
@update="update(index, $event, outer)"
@remove="remove(outer, index)"
@up="up(outer, index)"
@down="down(outer, index)"
/>
v-for="(content, index) in block.contents"
:key="content.id"
>
<content-element
:element="content"
class="content-block-form__segment"
@update="update(index, $event, outer)"
@remove="remove(outer, index)"
@up="up(outer, index)"
@down="down(outer, index)"
@top="top(outer, index)"
@bottom="bottom(outer, index)"
/>
<add-content-link
class="content-block-form__add-button"
@click="addBlock(outer, index)"
/>
</li>
</ol>
<add-content-link
class="content-block-form__add-button"
@click="addBlock(outer, index)"
/>
</li>
</ol>
</div>
<!-- If the block is a single element -->
<content-element
:element="block"
@ -73,11 +86,12 @@
@remove="remove(outer)"
@up="up(outer)"
@down="down(outer)"
@top="top(outer)"
@bottom="bottom(outer)"
/>
<!-- Add element after the looped item -->
<add-content-link
class="content-block-form__segment"
@click="addBlock(outer)"
/>
</div>
@ -107,23 +121,25 @@
import AddContentLink from '@/components/content-block-form/AddContentLink';
import ContentElement from '@/components/content-block-form/ContentElement';
import {removeAtIndex, replaceAtIndex, swapElements} from '@/graphql/immutable-operations';
import {moveToIndex, removeAtIndex, replaceAtIndex, swapElements} from '@/graphql/immutable-operations';
import {CHOOSER, transformInnerContents} from '@/components/content-block-form/helpers';
import ContentElementActions from '@/components/content-block-form/ContentElementActions';
export default {
props: {
title: {
type: String,
default: ''
default: '',
},
contentBlock: {
type: Object,
required: true
}
required: true,
},
},
components: {
ContentElementActions,
ContentElement,
AddContentLink,
InputWithLabel,
@ -144,7 +160,7 @@
computed: {
isValid() {
return this.localContentBlock.title > '';
}
},
},
methods: {
update(index, element, parent) {
@ -153,7 +169,7 @@
this.localContentBlock.contents = [
...this.localContentBlock.contents.slice(0, index),
element,
...this.localContentBlock.contents.slice(index + 1)
...this.localContentBlock.contents.slice(index + 1),
];
} else {
const parentBlock = this.localContentBlock.contents[parent];
@ -166,9 +182,9 @@
...parentBlock.contents.slice(0, index),
element,
...parentBlock.contents.slice(index + 1),
]
],
},
...this.localContentBlock.contents.slice(parent + 1)
...this.localContentBlock.contents.slice(parent + 1),
];
}
},
@ -185,20 +201,20 @@
id: -1,
type: CHOOSER,
},
...block.contents.slice(innerIndex + 1)
]
...block.contents.slice(innerIndex + 1),
],
},
...this.localContentBlock.contents.slice(afterOuterIndex + 1)
...this.localContentBlock.contents.slice(afterOuterIndex + 1),
];
} else {
this.localContentBlock.contents = [
...this.localContentBlock.contents.slice(0, afterOuterIndex+1),
...this.localContentBlock.contents.slice(0, afterOuterIndex + 1),
{
id: -1,
type: CHOOSER,
includeListOption: true
includeListOption: true,
},
...this.localContentBlock.contents.slice(afterOuterIndex+1)
...this.localContentBlock.contents.slice(afterOuterIndex + 1),
];
}
},
@ -219,17 +235,45 @@
const outerElement = contents[outer];
const newOuterElement = {
...outerElement,
contents: swapElements(outerElement.contents, inner, inner + distance)
contents: swapElements(outerElement.contents, inner, inner + distance),
};
this.localContentBlock.contents = replaceAtIndex(contents, outer, newOuterElement);
}
},
up(outer, inner){
top(outer, inner) {
if (inner === undefined) {
this.localContentBlock.contents = moveToIndex(this.localContentBlock.contents, outer, 0);
} else {
const {contents} = this.localContentBlock;
const outerElement = contents[outer];
const newOuterElement = {
...outerElement,
contents: moveToIndex(outerElement.contents, inner, 0),
};
this.localContentBlock.contents = replaceAtIndex(contents, outer, newOuterElement);
}
},
up(outer, inner) {
this.shift(outer, inner, -1);
},
down(outer, inner){
down(outer, inner) {
this.shift(outer, inner, 1);
},
bottom(outer, inner) {
if (inner === undefined) {
const maxIndex = this.localContentBlock.contents.length - 1;
this.localContentBlock.contents = moveToIndex(this.localContentBlock.contents, outer, maxIndex);
} else {
const {contents} = this.localContentBlock;
const outerElement = contents[outer];
const maxIndex = outerElement.contents.length - 1;
const newOuterElement = {
...outerElement,
contents: moveToIndex(outerElement.contents, inner, maxIndex),
};
this.localContentBlock.contents = replaceAtIndex(contents, outer, newOuterElement);
}
},
executeRemoval(outer, inner) {
if (inner === undefined) {
// not a list item container, just remove the element from the outer array
@ -245,7 +289,7 @@
*/
let element = {
...this.localContentBlock.contents[outer],
contents: innerContents
contents: innerContents,
};
this.localContentBlock.contents = replaceAtIndex(this.localContentBlock.contents, outer, element);
} else {
@ -257,7 +301,7 @@
save(contentBlock) {
this.$emit('save', contentBlock);
},
}
},
};
</script>
@ -287,12 +331,21 @@
}
&__segment {
display: flex;
flex-direction: column;
align-items: stretch;
margin-bottom: $large-spacing;
:last-child {
margin-bottom: 0;
}
}
&__actions {
align-self: flex-end;
}
&__content {
grid-area: content;
overflow-x: visible;

View File

@ -1,18 +1,26 @@
<template>
<div class="content-element">
<!-- Element Chooser if element has chooser type or no type -->
<content-block-element-chooser-widget
:class="['content-element__component', 'content-element__chooser']"
v-bind="element"
v-if="isChooser"
@change-type="changeType"
/>
<!-- Content Forms -->
<content-form-section
:title="title"
:has-actions="true"
:icon="icon"
v-else
@top="$emit('top')"
@up="$emit('up')"
@down="$emit('down')"
@bottom="$emit('bottom')"
@remove="$emit('remove')"
>
<div class="content-element__section">
<!-- Form depending on type of element -->
<component
:class="['content-element__component']"
v-bind="element"
@ -28,27 +36,6 @@
@assignment-change-title="changeAssignmentTitle"
@assignment-change-assignment="changeAssignmentAssignment"
/>
<a
class="contents-form__remove icon-button"
@click="$emit('remove')"
>
<trash-icon
class="contents-form__trash-icon icon-button__icon"
/>
</a>
<!-- <a-->
<!-- class="button"-->
<!-- @click="$emit('up')"-->
<!-- >-->
<!-- Up-->
<!-- </a>-->
<!-- <a-->
<!-- class="button"-->
<!-- @click="$emit('down')"-->
<!-- >-->
<!-- Down-->
<!-- </a>-->
</div>
</content-form-section>
</div>
@ -56,6 +43,7 @@
<script>
import ContentFormSection from '@/components/content-block-form/ContentFormSection';
import ContentElementActions from '@/components/content-block-form/ContentElementActions';
const TrashIcon = () => import(/* webpackChunkName: "icons" */'@/components/icons/TrashIcon');
const ContentBlockElementChooserWidget = () => import(/* webpackChunkName: "content-forms" */'@/components/content-forms/ContentBlockElementChooserWidget');
const LinkForm = () => import(/* webpackChunkName: "content-forms" */'@/components/content-forms/LinkForm');
@ -77,6 +65,7 @@
},
components: {
ContentElementActions,
ContentFormSection,
TrashIcon,
ContentBlockElementChooserWidget,
@ -170,6 +159,9 @@
},
});
},
debug(arg) {
console.log(arg);
},
changeUrl(value) {
this._updateProperty(value, 'url');
},
@ -261,9 +253,18 @@
@import '~styles/helpers';
.content-element {
display: flex;
flex-direction: column;
&__actions {
display: inline-flex;
justify-self: flex-end;
align-self: flex-end;
}
&__section {
display: grid;
grid-template-columns: 1fr 50px;
//grid-template-columns: 1fr 50px;
grid-auto-rows: auto;
/*width: 95%; // reserve space for scrollbar*/
}

View File

@ -0,0 +1,129 @@
<template>
<div class="content-element-actions">
<button
class="icon-button"
@click.stop="toggle(true)"
>
<ellipses class="icon-button__icon" />
</button>
<widget-popover
class="content-element-actions__popover"
:no-padding="true"
v-if="show"
@hide-me="toggle(false)"
>
<section class="content-element-actions__section">
<button-with-icon-and-text
class="content-element-actions__button"
:large="true"
icon="arrow-thin-top"
text="Ganz nach oben verschieben"
@click="emitAndClose('move-top')"
/>
<button-with-icon-and-text
class="content-element-actions__button"
:large="true"
icon="arrow-thin-up"
text="Nach oben verschieben"
@click="emitAndClose('move-up')"
/>
<button-with-icon-and-text
class="content-element-actions__button"
:large="true"
icon="arrow-thin-down"
text="Nach unten verschieben"
@click="emitAndClose('move-down')"
/>
<button-with-icon-and-text
class="content-element-actions__button"
:large="true"
icon="arrow-thin-bottom"
text="Ganz nach unten verschieben"
@click="emitAndClose('move-bottom')"
/>
</section>
<section class="content-element-actions__section">
<button-with-icon-and-text
class="content-element-actions__button"
:large="true"
icon="trash-icon"
text="Löschen"
@click="emitAndClose('remove')"
/>
</section>
</widget-popover>
</div>
</template>
<script>
import WidgetPopover from '@/components/ui/WidgetPopover';
import Ellipses from '@/components/icons/Ellipses';
import ButtonWithIconAndText from '@/components/ui/ButtonWithIconAndText';
export default {
components: {ButtonWithIconAndText, Ellipses, WidgetPopover},
data: () => ({
show: false,
}),
methods: {
toggle(show) {
this.show = show;
},
close() {
this.show = false;
},
up() {
this.$emit('move-up');
this.close();
},
down() {
this.$emit('move-down');
this.close();
},
top() {
this.$emit('move-top');
this.close();
},
bottom() {
this.$emit('move-bottom');
this.close();
},
emitAndClose(event) {
this.$emit(event);
this.close();
}
},
};
</script>
<style scoped lang="scss">
@import '~styles/helpers';
.content-element-actions {
position: relative;
&__popover {
white-space: nowrap;
top: 100%;
transform: translateY($small-spacing);
}
&__section {
border-bottom: 1px solid $color-silver-dark;
padding: $medium-spacing $small-spacing;
&:last-child {
border-bottom: 0;
}
}
&__button {
margin-bottom: $medium-spacing;
}
}
</style>

View File

@ -6,12 +6,25 @@
:is="icon"
/> <span class="content-form-section__title">{{ title }}</span>
</h2>
<slot />
<content-element-actions
class="content-form-section__actions"
v-if="hasActions"
@remove="$emit('remove')"
@move-top="$emit('top')"
@move-up="$emit('up')"
@move-down="$emit('down')"
@move-bottom="$emit('bottom')"
/>
<div class="content-form-section__content">
<slot />
</div>
</div>
</template>
<script>
import formElementIcons from '@/components/ui/form-element-icons';
import ContentElementActions from '@/components/content-block-form/ContentElementActions';
export default {
props: {
@ -22,9 +35,14 @@
icon: {
type: String,
default: ''
},
hasActions: {
type: Boolean,
default: false
}
},
components: {
ContentElementActions,
...formElementIcons
}
};
@ -39,9 +57,23 @@
padding: $small-spacing $medium-spacing;
margin-bottom: $medium-spacing;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto;
grid-template-areas: 'h a' 'c c';
align-items: center;
grid-row-gap: $medium-spacing;
&__heading {
display: flex;
align-items: center;
grid-area: h;
margin-bottom: 0;
}
&__actions {
grid-area: a;
justify-self: end;
}
&__title {
@ -54,5 +86,9 @@
height: 28px;
margin-right: $small-spacing;
}
&__content {
grid-area: c;
}
}
</style>

View File

@ -0,0 +1,11 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
>
<path
d="M79 80.8H51.6l.1-.1 25.7-25.8c1-1 1-2.6 0-3.5-1-1-2.6-1-3.5 0L52.5 72.9V16.7c0-1.4-1.1-2.5-2.5-2.5s-2.5 1.1-2.5 2.5v56.2L26.1 51.4c-1-1-2.6-1-3.5 0-1 1-1 2.6 0 3.5l25.7 25.8.1.1H21c-1.4 0-2.5 1.1-2.5 2.5s1.1 2.5 2.5 2.5h58c1.4 0 2.5-1.1 2.5-2.5s-1.1-2.5-2.5-2.5z"
id="shape"
/>
</svg>
</template>

View File

@ -0,0 +1,12 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
xml:space="preserve"
>
<path
d="M77.5 55.7c-1-1-2.6-1-3.5 0L52.5 77.2V21.1c0-1.4-1.1-2.5-2.5-2.5s-2.5 1.1-2.5 2.5v56.2L26.1 55.8c-1-1-2.6-1-3.5 0-1 1-1 2.6 0 3.5l25.7 25.8c.1.1.2.2.4.3 0 0 .1 0 .1.1.1.1.2.1.3.2h.1c.1 0 .2.1.3.1h1c.1 0 .2-.1.3-.1h.1c.1 0 .2-.1.3-.2 0 0 .1 0 .1-.1.1-.1.3-.2.4-.3l25.7-25.8c1.1-1 1.1-2.6.1-3.6z"
id="arrow-thin-down"
/>
</svg>
</template>

View File

@ -0,0 +1,13 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="100"
height="100"
viewBox="0 0 100 100"
id="shape"
>
<path
d="M79,14.2H21a2.5,2.5,0,0,0,0,5H48.35l-.12.1h0L22.52,45.08a2.5,2.5,0,1,0,3.54,3.53L47.5,27.12V83.3a2.5,2.5,0,1,0,5,0V27.12L73.94,48.61a2.5,2.5,0,1,0,3.54-3.53L51.77,19.3h0l-.12-.1H79a2.5,2.5,0,0,0,0-5Z"
/>
</svg>
</template>

View File

@ -0,0 +1,11 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
xml:space="preserve"
>
<path
d="M77.5 45.1 51.8 19.3c-.5-.5-1.1-.7-1.8-.7s-1.3.3-1.8.7L22.5 45.1c-1 1-1 2.6 0 3.5 1 1 2.6 1 3.5 0l21.4-21.5v56.2c0 1.4 1.1 2.5 2.5 2.5s2.5-1.1 2.5-2.5V27.1l21.4 21.5c.5.5 1.1.7 1.8.7.6 0 1.3-.2 1.8-.7 1.1-1 1.1-2.5.1-3.5z"
id="arrow-thin-up"
/></svg>
</template>

View File

@ -1,5 +1,8 @@
<template>
<a class="button-with-icon-and-text">
<a
:class="[ 'button-with-icon-and-text', {'button-with-icon-and-text--large': large}]"
@click="$emit('click')"
>
<component
class="button-with-icon-and-text__icon"
:is="icon"
@ -9,8 +12,7 @@
</template>
<script>
const DocumentIcon = () => import(/* webpackChunkName: "icons" */'@/components/icons/DocumentIcon');
const DocumentWithLinesIcon = () => import(/* webpackChunkName: "icons" */'@/components/icons/DocumentWithLinesIcon');
import formElementIcons from '@/components/ui/form-element-icons';
export default {
props: {
@ -21,12 +23,15 @@
text: {
type: String,
default: ''
},
large: {
type: Boolean,
default: false
}
},
components: {
DocumentIcon,
DocumentWithLinesIcon
...formElementIcons
}
};
</script>
@ -48,5 +53,13 @@
&__text {
@include small-text;
}
$p: &;
&--large {
#{$p}__text {
@include large-link;
}
}
}
</style>

View File

@ -4,7 +4,7 @@
class="widget-popover"
v-click-outside="hidePopover"
>
<ul class="widget-popover__links popover-links">
<ul :class="['widget-popover__links popover-links', {'popover-links--no-padding': noPadding}]">
<slot />
</ul>
</div>
@ -16,6 +16,10 @@
mobile: {
type: Boolean,
default: false
},
noPadding: {
type: Boolean,
default: false
}
},
@ -26,7 +30,3 @@
}
};
</script>
<style scoped lang="scss">
</style>

View File

@ -5,7 +5,19 @@ const TextIcon = () => import(/* webpackChunkName: "icons" */'@/components/icons
const SpeechBubbleIcon = () => import(/* webpackChunkName: "icons" */'@/components/icons/SpeechBubbleIcon');
const DocumentIcon = () => import(/* webpackChunkName: "icons" */'@/components/icons/DocumentIcon');
const TitleIcon = () => import(/* webpackChunkName: "icons" */'@/components/icons/TitleIcon');
const DocumentWithLinesIcon = () => import(/* webpackChunkName: "icons" */'@/components/icons/DocumentWithLinesIcon');
const ArrowThinBottom = () => import(/* webpackChunkName: "icons" */'@/components/icons/ArrowThinBottom');
const ArrowThinDown = () => import(/* webpackChunkName: "icons" */'@/components/icons/ArrowThinDown');
const ArrowThinTop = () => import(/* webpackChunkName: "icons" */'@/components/icons/ArrowThinTop');
const ArrowThinUp = () => import(/* webpackChunkName: "icons" */'@/components/icons/ArrowThinUp');
const TrashIcon = () => import(/* webpackChunkName: "icons" */'@/components/icons/TrashIcon');
/*
for icons with a single word, leave the *-icon name, to prevent conflicts
for others, both is fine: arrow-thin-up-icon or arrow-thin-up
*/
export default {
LinkIcon,
VideoIcon,
@ -13,5 +25,11 @@ export default {
TextIcon,
SpeechBubbleIcon,
DocumentIcon,
TitleIcon
TitleIcon,
DocumentWithLinesIcon,
ArrowThinBottom,
ArrowThinDown,
ArrowThinTop,
ArrowThinUp,
TrashIcon
};

View File

@ -42,3 +42,16 @@ export const swapElements = (arr, idx1, idx2)=>{
...arr.slice(larger+1)
];
};
export const moveToIndex = (arr, from, to) => {
const maxLength = arr.length - 1;
if (from < 0 || to < 0 || from > maxLength || to > maxLength) {
throw new Error('Index out of bounds');
}
const element = arr[from];
const newArr = [...arr.slice(0, from), ...arr.slice(from+1)];
return [
...newArr.slice(0, to),
element,
...newArr.slice(to)
];
};

View File

@ -24,6 +24,10 @@
flex-direction: column;
padding: $small-spacing;
&--no-padding {
padding: 0;
}
&__icon {
width: 25px;
height: 25px;

View File

@ -1,4 +1,12 @@
import {removeAtIndex, replaceAtIndex, insertAtIndex, pushToArray, swapElements} from '@/graphql/immutable-operations';
import {
insertAtIndex,
moveToIndex,
pushToArray,
removeAtIndex,
replaceAtIndex,
swapElements,
} from '@/graphql/immutable-operations';
describe('Cache operations', () => {
it('removes at index', () => {
const original = [1, 2, 3];
@ -75,14 +83,14 @@ describe('Cache operations', () => {
expect(copy).toEqual([3, 2, 1]);
});
it('swaps neighbors', ()=>{
it('swaps neighbors', () => {
const original = [1, 2, 3, 4, 5];
const copy = swapElements(original, 3, 2);
expect(copy).toEqual([1, 2, 4, 3, 5]);
});
it('does nothing with wrong indices', ()=>{
it('does nothing with wrong indices', () => {
const original = [1, 2, 3, 4, 5];
const copy1 = swapElements(original, -1, 2);
const copy2 = swapElements(original, 1, 99);
@ -97,4 +105,15 @@ describe('Cache operations', () => {
expect(copy2.length).toBe(5);
expect(copy3.length).toBe(5);
});
it('moves to a specific index', () => {
const original = [1, 2, 3, 4, 5, 6, 7];
const toStart = moveToIndex(original, 3, 0);
const toEnd = moveToIndex(original, 3, original.length - 1);
const toMiddle = moveToIndex(original, 3, 2);
expect(toStart).toEqual([4, 1, 2, 3, 5, 6, 7]);
expect(toEnd).toEqual([1, 2, 3, 5, 6, 7, 4]);
expect(toMiddle).toEqual([1, 2, 4, 3, 5, 6, 7]);
});
});