Commit 270ad0a0 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '11089-allow-users-to-destroy-hide-designs' into 'master'

Front-End UI for design deletion

See merge request gitlab-org/gitlab!15034
parents c3ea95db 48357f7e
......@@ -47,6 +47,8 @@ to be enabled:
when an issue is deleted.
- Design Management
[isn't supported by Geo](https://gitlab.com/groups/gitlab-org/-/epics/1633) yet.
- Only the latest version of the designs can be deleted.
- Deleted designs cannot be recovered but you can see them on previous designs versions.
## The Design Management page
......@@ -77,6 +79,34 @@ to help summarize changes between versions.
| Modified (in the selected version) | ![Design Modified](img/design_modified_v12_3.png) |
| Added (in the selected version) | ![Design Added](img/design_added_v12_3.png) |
## Deleting designs
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11089) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.4.
There are two ways to delete designs: manually delete them
individually, or select a few of them to delete at once,
as shown below.
To delete a single design, click it to view it enlarged,
then click the trash icon on the top right corner and confirm
the deletion by clicking the **Delete** button on the modal window:
![Confirm design deletion](img/confirm_design_deletion_v12_4.png)
To delete multiple designs at once, on the design's list view,
first select the designs you want to delete:
![Select designs](img/select_designs_v12_4.png)
Once selected, click the **Delete selected** button to confirm the deletion:
![Delete multiple designs](img/delete_multiple_designs_v12_4.png)
NOTE: **Note:**
Only the latest version of the designs can be deleted.
Deleted designs are not permanently lost; they can be
viewed by browsing previous versions.
## Adding annotations to designs
When a design image is displayed, you can add annotations to it by clicking on
......
<script>
import { GlButton, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
import _ from 'underscore';
export default {
name: 'DeleteButton',
components: {
GlButton,
GlLoadingIcon,
GlModal,
},
directives: {
GlModalDirective,
},
props: {
isDeleting: {
type: Boolean,
required: false,
default: false,
},
buttonClass: {
type: String,
required: false,
default: '',
},
buttonVariant: {
type: String,
required: false,
default: '',
},
hasSelectedDesigns: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
modalId: _.uniqueId('design-deletion-confirmation-'),
};
},
};
</script>
<template>
<div>
<gl-modal
:modal-id="modalId"
:title="s__('DesignManagement|Delete designs confirmation')"
:ok-title="s__('DesignManagement|Delete')"
ok-variant="danger"
@ok="$emit('deleteSelectedDesigns')"
>
<p>{{ s__('DesignManagement|Are you sure you want to delete the selected designs?') }}</p>
</gl-modal>
<gl-button
v-gl-modal-directive="modalId"
:variant="buttonVariant"
:disabled="isDeleting || !hasSelectedDesigns"
:class="buttonClass"
>
<slot></slot>
</gl-button>
</div>
</template>
<script>
import { ApolloMutation } from 'vue-apollo';
import projectQuery from '../graphql/queries/project.query.graphql';
import destroyDesignMutation from '../graphql/mutations/destroyDesign.mutation.graphql';
import {
updateStoreAfterDesignsDelete,
onDesignDeletionError,
} from '../utils/design_management_utils';
export default {
components: {
ApolloMutation,
},
props: {
filenames: {
type: Array,
required: true,
},
projectPath: {
type: String,
required: true,
},
iid: {
type: String,
required: true,
},
},
computed: {
projectQueryBody() {
return {
query: projectQuery,
variables: { fullPath: this.projectPath, iid: this.iid, atVersion: null },
};
},
},
methods: {
onError(...args) {
onDesignDeletionError(...args);
},
updateStoreAfterDelete(
store,
{
data: { designManagementDelete },
},
) {
updateStoreAfterDesignsDelete(
store,
designManagementDelete,
this.projectQueryBody,
this.filenames,
);
},
},
destroyDesignMutation,
};
</script>
<template>
<ApolloMutation
v-slot="{ mutate, loading, error }"
:mutation="$options.destroyDesignMutation"
:variables="{
filenames,
projectPath,
iid,
}"
:update="updateStoreAfterDelete"
@error="onError"
v-on="$listeners"
>
<slot v-bind="{ mutate, loading, error }"></slot>
</ApolloMutation>
</template>
......@@ -80,6 +80,7 @@ export default {
__typename: 'NoteEdge',
node: createNote.note,
});
data.design.notesCount += 1;
store.writeQuery({ query: getDesignQuery, data });
},
})
......
<script>
import Design from './item.vue';
export default {
components: {
Design,
},
props: {
designs: {
type: Array,
required: true,
},
},
};
</script>
<template>
<ol class="list-unstyled row">
<li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-4 mb-3">
<design
:id="design.id"
:event="design.event"
:notes-count="design.notesCount"
:image="design.image"
:name="design.filename"
:updated-at="design.updatedAt"
/>
</li>
</ol>
</template>
......@@ -25,7 +25,7 @@ export default {
type: String,
required: true,
},
name: {
filename: {
type: String,
required: true,
},
......@@ -69,7 +69,7 @@ export default {
<router-link
:to="{
name: 'design',
params: { id: name },
params: { id: filename },
query: $route.query,
}"
class="card cursor-pointer text-plain js-design-list-item design-list-item"
......@@ -80,11 +80,13 @@ export default {
<icon :name="icon.name" :size="18" :class="icon.classes" />
</span>
</div>
<img :src="image" :alt="name" class="block ml-auto mr-auto mw-100 mh-100 design-img" />
<img :src="image" :alt="filename" class="block ml-auto mr-auto mw-100 mh-100 design-img" />
</div>
<div class="card-footer d-flex w-100">
<div class="d-flex flex-column str-truncated-100">
<span class="bold str-truncated-100" data-qa-selector="design_file_name">{{ name }}</span>
<span class="bold str-truncated-100" data-qa-selector="design_file_name">{{
filename
}}</span>
<span v-if="updatedAt" class="str-truncated-100">
{{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" />
</span>
......
<script>
import { __, sprintf } from '~/locale';
import { GlLoadingIcon } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import Pagination from './pagination.vue';
import DeleteButton from '../delete_button.vue';
import permissionsQuery from '../../graphql/queries/permissions.query.graphql';
import appDataQuery from '../../graphql/queries/appData.query.graphql';
export default {
components: {
GlLoadingIcon,
Icon,
Pagination,
DeleteButton,
},
mixins: [timeagoMixin],
props: {
......@@ -17,6 +19,10 @@ export default {
type: String,
required: true,
},
isDeleting: {
type: Boolean,
required: true,
},
name: {
type: String,
required: false,
......@@ -32,6 +38,39 @@ export default {
required: false,
default: () => ({}),
},
isLatestVersion: {
type: Boolean,
required: true,
},
},
data() {
return {
permissions: {
createDesign: false,
},
projectPath: '',
issueIid: null,
};
},
apollo: {
appData: {
query: appDataQuery,
manual: true,
result({ data: { projectPath, issueIid } }) {
this.projectPath = projectPath;
this.issueIid = issueIid;
},
},
permissions: {
query: permissionsQuery,
variables() {
return {
fullPath: this.projectPath,
iid: this.issueIid,
};
},
update: data => data.project.issue.userPermissions,
},
},
computed: {
updatedText() {
......@@ -40,6 +79,9 @@ export default {
updated_by: this.updatedBy.name,
});
},
canDeleteDesign() {
return this.permissions.createDesign;
},
},
};
</script>
......@@ -61,5 +103,13 @@ export default {
<small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
</div>
<pagination :id="id" class="ml-auto" />
<delete-button
v-if="isLatestVersion && canDeleteDesign"
:is-deleting="isDeleting"
button-variant="danger"
@deleteSelectedDesigns="$emit('delete')"
>
<icon :size="18" name="remove" />
</delete-button>
</header>
</template>
<script>
import UploadButton from './button.vue';
import DesignVersionDropdown from './design_version_dropdown.vue';
export default {
components: {
UploadButton,
DesignVersionDropdown,
},
props: {
isSaving: {
type: Boolean,
required: true,
},
canUploadDesign: {
type: Boolean,
required: true,
},
},
methods: {
onFileUploadChange(files) {
this.$emit('upload', files);
},
},
};
</script>
<template>
<header class="row-content-block border-top-0 p-2 d-flex">
<div class="d-flex justify-content-between align-items-center w-100">
<design-version-dropdown />
<upload-button v-if="canUploadDesign" :is-saving="isSaving" @upload="onFileUploadChange" />
</div>
</header>
</template>
#import "../fragments/version.fragment.graphql"
mutation destroyDesign($filenames: [String!]!, $projectPath: ID!, $iid: ID!) {
designManagementDelete(input: { projectPath: $projectPath, iid: $iid, filenames: $filenames }) {
version {
...VersionListItem
}
}
}
#import "../fragments/designList.fragment.graphql"
#import "../fragments/version.fragment.graphql"
query project($fullPath: ID!, $iid: String!, $atVersion: ID) {
project(fullPath: $fullPath) {
......@@ -15,8 +16,7 @@ query project($fullPath: ID!, $iid: String!, $atVersion: ID) {
versions {
edges {
node {
id
sha
...VersionListItem
}
}
}
......
import { propertyOf } from 'underscore';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import projectQuery from '../graphql/queries/project.query.graphql';
......@@ -16,7 +17,13 @@ export default {
atVersion: this.designsVersion,
};
},
update: data => extractNodes(data.project.issue.designs.designs),
update: data => {
const designEdges = propertyOf(data)(['project', 'issue', 'designs', 'designs']);
if (designEdges) {
return extractNodes(designEdges);
}
return [];
},
error() {
this.error = true;
},
......
import projectQuery from '../graphql/queries/project.query.graphql';
import appDataQuery from '../graphql/queries/appData.query.graphql';
import { findVersionId } from '../utils/design_management_utils';
export default {
apollo: {
......@@ -36,6 +37,13 @@ export default {
? `gid://gitlab/DesignManagement::Version/${this.$route.query.version}`
: null;
},
isLatestVersion() {
if (this.allVersions.length > 0) {
const versionId = findVersionId(this.allVersions[0].node.id);
return !this.$route.query.version || this.$route.query.version === versionId;
}
return true;
},
},
data() {
return {
......
......@@ -9,6 +9,7 @@ import DesignImage from '../../components/image.vue';
import DesignOverlay from '../../components/design_overlay.vue';
import DesignDiscussion from '../../components/design_notes/design_discussion.vue';
import DesignReplyForm from '../../components/design_notes/design_reply_form.vue';
import DesignDestroyer from '../../components/design_destroyer.vue';
import getDesignQuery from '../../graphql/queries/getDesign.query.graphql';
import createImageDiffNoteMutation from '../../graphql/mutations/createImageDiffNote.mutation.graphql';
import { extractDiscussions } from '../../utils/design_management_utils';
......@@ -18,6 +19,7 @@ export default {
DesignImage,
DesignOverlay,
DesignDiscussion,
DesignDestroyer,
Toolbar,
DesignReplyForm,
GlLoadingIcon,
......@@ -142,6 +144,7 @@ export default {
},
};
data.design.discussions.edges.push(newDiscussion);
data.design.notesCount += 1;
store.writeQuery({ query: getDesignQuery, data });
},
})
......@@ -193,12 +196,25 @@ export default {
<gl-loading-icon v-if="isLoading" size="xl" class="align-self-center" />
<template v-else>
<div class="d-flex flex-column w-100">
<toolbar
:id="id"
:name="design.filename"
:updated-at="design.updatedAt"
:updated-by="design.updatedBy"
/>
<design-destroyer
:filenames="[design.filename]"
:project-path="projectPath"
:iid="issueIid"
@done="$router.push({ name: 'designs' })"
@error="$router.push({ name: 'designs' })"
>
<template v-slot="{ mutate, loading, error }">
<toolbar
:id="id"
:is-deleting="loading"
:name="design.filename"
:updated-at="design.updatedAt"
:updated-by="design.updatedBy"
:is-latest-version="isLatestVersion"
@delete="mutate()"
/>
</template>
</design-destroyer>
<div class="d-flex flex-column w-100 h-100 mh-100 position-relative">
<design-image
:image="design.image"
......
<script>
import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import { GlLoadingIcon, GlEmptyState, GlButton } from '@gitlab/ui';
import _ from 'underscore';
import createFlash from '~/flash';
import { s__, sprintf } from '~/locale';
import DesignList from '../components/list/index.vue';
import UploadForm from '../components/upload/form.vue';
import UploadButton from '../components/upload/button.vue';
import DeleteButton from '../components/delete_button.vue';
import Design from '../components/list/item.vue';
import DesignDestroyer from '../components/design_destroyer.vue';
import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue';
import uploadDesignMutation from '../graphql/mutations/uploadDesign.mutation.graphql';
import permissionsQuery from '../graphql/queries/permissions.query.graphql';
import allDesignsMixin from '../mixins/all_designs';
import projectQuery from '../graphql/queries/project.query.graphql';
import allDesignsMixin from '../mixins/all_designs';
const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
export default {
components: {
GlLoadingIcon,
DesignList,
UploadForm,
UploadButton,
GlEmptyState,
GlButton,
Design,
DesignDestroyer,
DesignVersionDropdown,
DeleteButton,
},
mixins: [allDesignsMixin],
apollo: {
......@@ -40,6 +45,7 @@ export default {
createDesign: false,
},
isSaving: false,
selectedDesigns: [],
};
},
computed: {
......@@ -49,12 +55,29 @@ export default {
canCreateDesign() {
return this.permissions.createDesign;
},
showUploadForm() {
return this.canCreateDesign && this.hasDesigns;
showToolbar() {
return this.canCreateDesign && this.allVersions.length > 0;
},
hasDesigns() {
return this.designs.length > 0;
},
hasSelectedDesigns() {
return this.selectedDesigns.length > 0;
},
canDeleteDesigns() {
return this.isLatestVersion && this.hasSelectedDesigns;
},
projectQueryBody() {
return {
query: projectQuery,
variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null },
};
},
selectAllButtonText() {
return this.hasSelectedDesigns
? s__('DesignManagement|Deselect all')
: s__('DesignManagement|Select all');
},
},
methods: {
onUploadDesign(files) {
......@@ -83,6 +106,8 @@ export default {
image: '',
filename: file.name,
fullPath: '',
notesCount: 0,
event: 'NONE',
diffRefs: {
__typename: 'DiffRefs',
baseSha: '',
......@@ -116,10 +141,7 @@ export default {
hasUpload: true,
},
update: (store, { data: { designManagementUpload } }) => {
const data = store.readQuery({
query: projectQuery,
variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null },
});
const data = store.readQuery(this.projectQueryBody);
const newDesigns = data.project.issue.designs.designs.edges.reduce((acc, design) => {
if (!acc.find(d => d.filename === design.node.filename)) {
......@@ -145,38 +167,26 @@ export default {
...data.project.issue.designs.versions.edges,
];
const newQueryData = {
project: {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
__typename: 'Project',
id: '',
issue: {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
__typename: 'Issue',
designs: {
__typename: 'DesignCollection',
designs: {
__typename: 'DesignConnection',
edges: newDesigns.map(design => ({
__typename: 'DesignEdge',
node: design,
})),
},
versions: {
__typename: 'DesignVersionConnection',
edges: newVersions,
},
},
},
const updatedDesigns = {
__typename: 'DesignCollection',
designs: {
__typename: 'DesignConnection',
edges: newDesigns.map(design => ({
__typename: 'DesignEdge',
node: design,
})),
},
versions: {
__typename: 'DesignVersionConnection',
edges: newVersions,
},
};
data.project.issue.designs = updatedDesigns;
store.writeQuery({
query: projectQuery,
variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null },
data: newQueryData,
...this.projectQueryBody,
data,
});
},
optimisticResponse: {
......@@ -200,25 +210,86 @@ export default {
this.isSaving = false;
});
},
changeSelectedDesigns(filename) {
if (this.isDesignSelected(filename)) {
this.selectedDesigns = this.selectedDesigns.filter(design => design !== filename);
} else {
this.selectedDesigns.push(filename);
}
},
toggleDesignsSelection() {
if (this.hasSelectedDesigns) {
this.selectedDesigns = [];
} else {
this.selectedDesigns = this.designs.map(design => design.filename);
}
},
isDesignSelected(filename) {
return this.selectedDesigns.includes(filename);
},
onDesignDelete() {
this.selectedDesigns = [];
if (this.$route.query.version) this.$router.push({ name: 'designs' });
},
},
beforeRouteUpdate(to, from, next) {
this.selectedDesigns = [];
next();
},
};
</script>
<template>
<div>
<upload-form
v-if="showUploadForm"
:can-upload-design="canCreateDesign"
:is-saving="isSaving"
:all-versions="allVersions"
@upload="onUploadDesign"
/>
<header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex">
<div class="d-flex justify-content-between align-items-center w-100">
<design-version-dropdown />
<div class="d-flex">
<gl-button
v-if="isLatestVersion"
variant="link"
class="mr-2 js-select-all"
@click="toggleDesignsSelection"
>{{ selectAllButtonText }}</gl-button
>
<design-destroyer
v-slot="{ mutate, loading, error }"
:filenames="selectedDesigns"
:project-path="projectPath"
:iid="issueIid"
@done="onDesignDelete"
>
<delete-button
:is-deleting="loading"
button-class="btn-danger btn-inverted mr-2"
:has-selected-designs="hasSelectedDesigns"
@deleteSelectedDesigns="mutate()"
>
{{ s__('DesignManagement|Delete selected') }}
<gl-loading-icon v-if="loading" inline class="ml-1" />
</delete-button>
</design-destroyer>
<upload-button v-if="canCreateDesign" :is-saving="isSaving" @upload="onUploadDesign" />
</div>
</div>
</header>
<div class="mt-4">
<gl-loading-icon v-if="isLoading" size="md" />
<div v-else-if="error" class="alert alert-danger">
{{ __('An error occurred while loading designs. Please try again.') }}
</div>
<design-list v-else-if="hasDesigns" :designs="designs" />
<ol v-else-if="hasDesigns" class="list-unstyled row">
<li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-4 mb-3">
<design v-bind="design" />
<input
v-if="isLatestVersion && canCreateDesign"
:checked="isDesignSelected(design.filename)"
type="checkbox"
class="design-checkbox"
@change="changeSelectedDesigns(design.filename)"
/>
</li>
</ol>
<gl-empty-state
v-else
:title="s__('DesignManagement|The one place for your designs')"
......
import { s__ } from '~/locale';
import createFlash from '~/flash';
/**
* Returns formatted array that doesn't contain
* `edges`->`node` nesting
......@@ -29,3 +32,50 @@ export const extractDiscussions = discussions =>
export const extractCurrentDiscussion = (discussions, id) =>
discussions.edges.find(({ node }) => node.id === id);
const deleteDesignsFromStore = (store, query, selectedDesigns) => {
const data = store.readQuery(query);
const changedDesigns = data.project.issue.designs.designs.edges.filter(
({ node }) => !selectedDesigns.includes(node.filename),
);
data.project.issue.designs.designs.edges = [...changedDesigns];
store.writeQuery({
...query,
data,
});
};
const addNewVersionToStore = (store, query, version) => {
if (!version) return;
const data = store.readQuery(query);
const newEdge = { node: version, __typename: 'DesignVersionEdge' };
data.project.issue.designs.versions.edges = [
newEdge,
...data.project.issue.designs.versions.edges,
];
store.writeQuery({
...query,
data,
});
};
export const onDesignDeletionError = e => {
createFlash(s__('DesignManagement|We could not delete design(s). Please try again.'));
throw e;
};
export const updateStoreAfterDesignsDelete = (store, data, query, designs) => {
if (data.errors) {
onDesignDeletionError(new Error(data.errors));
} else {
deleteDesignsFromStore(store, query, designs);
addNewVersionToStore(store, query, data.version);
}
};
export const findVersionId = id => id.match('::Version/(.+$)')[1];
......@@ -11,6 +11,12 @@
right: $gl-padding;
}
.design-checkbox {
position: absolute;
top: $gl-padding;
left: 30px;
}
.image-notes {
overflow-y: scroll;
padding: 0 $gl-padding;
......
---
title: Front-End UI for design deletion
merge_request: 15034
author:
type: added
import { shallowMount } from '@vue/test-utils';
import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
import BatchDeleteButton from 'ee/design_management/components/delete_button.vue';
describe('Batch delete button component', () => {
let wrapper;
const findButton = () => wrapper.find(GlButton);
const findModal = () => wrapper.find(GlModal);
function createComponent(isDeleting = false) {
wrapper = shallowMount(BatchDeleteButton, {
propsData: {
isDeleting,
},
sync: false,
directives: {
GlModalDirective,
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('renders non-disabled button by default', () => {
createComponent();
expect(findButton().exists()).toBe(true);
expect(findButton().attributes('disabled')).toBeFalsy();
});
it('renders disabled button when design is deleting', () => {
createComponent(true);
expect(findButton().attributes('disabled')).toBeTruthy();
});
it('emits `deleteSelectedDesigns` event on modal ok click', () => {
createComponent();
findButton().vm.$emit('click');
findModal().vm.$emit('ok');
expect(wrapper.emitted().deleteSelectedDesigns).toBeTruthy();
});
});
......@@ -73,7 +73,7 @@ describe('Design discussions component', () => {
it('hides reply placeholder and opens form on placeholder click', () => {
findReplyPlaceholder().trigger('click');
wrapper.vm.$nextTick(() => {
return wrapper.vm.$nextTick().then(() => {
expect(findReplyPlaceholder().exists()).toBe(false);
expect(findReplyForm().exists()).toBe(true);
});
......@@ -85,16 +85,17 @@ describe('Design discussions component', () => {
isFormRendered: true,
});
wrapper.vm.$nextTick(() => {
findReplyForm().vm.$emit('submitForm');
return wrapper.vm
.$nextTick()
.then(() => {
findReplyForm().vm.$emit('submitForm');
expect(mutate).toHaveBeenCalledWith(mutationVariables);
expect(mutate).toHaveBeenCalledWith(mutationVariables);
const addComment = wrapper.vm.addDiscussionComment();
return addComment.then(() => {
return wrapper.vm.addDiscussionComment();
})
.then(() => {
expect(findReplyForm().exists()).toBe(false);
});
});
});
});
......@@ -106,7 +106,7 @@ describe('Design overlay component', () => {
},
});
wrapper.vm.$nextTick(() => {
return wrapper.vm.$nextTick().then(() => {
expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 30px;');
});
});
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management list component renders list 1`] = `
<ol
class="list-unstyled row"
>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-stub
event="NONE"
id="1"
image="test"
name="test"
notescount="2"
updatedat="01-01-2019"
/>
</li>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-stub
event="NONE"
id="2"
image="test"
name="test"
notescount="2"
updatedat="01-01-2019"
/>
</li>
</ol>
`;
import { shallowMount } from '@vue/test-utils';
import List from 'ee/design_management/components/list/index.vue';
const createMockDesign = id => ({
id,
filename: 'test',
image: 'test',
event: 'NONE',
notesCount: 2,
updatedAt: '01-01-2019',
});
describe('Design management list component', () => {
let wrapper;
function createComponent() {
wrapper = shallowMount(List, {
sync: false,
propsData: {
designs: [createMockDesign(1), createMockDesign(2)],
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('renders list', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
});
......@@ -16,7 +16,7 @@ describe('Design management list item component', () => {
router,
propsData: {
id: 1,
name: 'test',
filename: 'test',
image: 'http://via.placeholder.com/300',
event,
notesCount,
......
......@@ -32,5 +32,16 @@ exports[`Design management toolbar component renders design and updated data 1`]
class="ml-auto"
id="1"
/>
<deletebutton-stub
buttonclass=""
buttonvariant="danger"
hasselecteddesigns="true"
>
<icon-stub
name="remove"
size="18"
/>
</deletebutton-stub>
</header>
`;
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueRouter from 'vue-router';
import Toolbar from 'ee/design_management/components/toolbar/index.vue';
import DeleteButton from 'ee/design_management/components/delete_button.vue';
const localVue = createLocalVue();
localVue.use(VueRouter);
......@@ -20,7 +21,7 @@ const RouterLinkStub = {
describe('Design management toolbar component', () => {
let wrapper;
function createComponent(isLoading = false) {
function createComponent(isLoading = false, createDesign = true, props) {
const updatedAt = new Date();
updatedAt.setHours(updatedAt.getHours() - 1);
......@@ -30,23 +31,38 @@ describe('Design management toolbar component', () => {
router,
propsData: {
id: '1',
isLatestVersion: true,
isLoading,
isDeleting: false,
name: 'test.jpg',
updatedAt: updatedAt.toString(),
updatedBy: {
name: 'Test Name',
},
...props,
},
stubs: {
'router-link': RouterLinkStub,
},
});
wrapper.setData({
permissions: {
createDesign,
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('renders design and updated data', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
it('links back to designs list', () => {
......@@ -61,4 +77,37 @@ describe('Design management toolbar component', () => {
},
});
});
it('renders delete button on latest designs version with logged in user', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(DeleteButton).exists()).toBe(true);
});
});
it('does not render delete button on non-latest version', () => {
createComponent(false, true, { isLatestVersion: false });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(DeleteButton).exists()).toBe(false);
});
});
it('does not render delete button when user is not logged in', () => {
createComponent(false, false);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(DeleteButton).exists()).toBe(false);
});
});
it('emits `delete` event on deleteButton `deleteSelectedDesigns` event', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
wrapper.find(DeleteButton).vm.$emit('deleteSelectedDesigns');
expect(wrapper.emitted().delete).toBeTruthy();
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management upload form component hides button if cant upload 1`] = `
<header
class="row-content-block border-top-0 p-2 d-flex"
issueiid=""
projectpath=""
>
<div
class="d-flex justify-content-between align-items-center w-100"
>
<designversiondropdown-stub />
<!---->
</div>
</header>
`;
exports[`Design management upload form component renders upload design button 1`] = `
<header
class="row-content-block border-top-0 p-2 d-flex"
issueiid=""
projectpath=""
>
<div
class="d-flex justify-content-between align-items-center w-100"
>
<designversiondropdown-stub />
<uploadbutton-stub />
</div>
</header>
`;
import { shallowMount } from '@vue/test-utils';
import UploadForm from 'ee/design_management/components/upload/form.vue';
describe('Design management upload form component', () => {
let wrapper;
function createComponent(isSaving = false, canUploadDesign = true) {
wrapper = shallowMount(UploadForm, {
sync: false,
propsData: {
isSaving,
canUploadDesign,
projectPath: '',
issueIid: '',
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('renders upload design button', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('hides button if cant upload', () => {
createComponent(false, false);
expect(wrapper.element).toMatchSnapshot();
});
describe('onFileUploadChange', () => {
it('emits upload event', () => {
createComponent();
wrapper.vm.onFileUploadChange('test');
expect(wrapper.emitted().upload[0]).toEqual(['test']);
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management index page designs renders designs list 1`] = `
exports[`Design management index page designs does not render toolbar when there is no permission 1`] = `
<div>
<uploadform-stub
all-versions=""
canuploaddesign="true"
<!---->
<div
class="mt-4"
>
<ol
class="list-unstyled row"
>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-stub
event="NONE"
filename="design-1-name"
id="design-1"
image="design-1-image"
notescount="0"
/>
<!---->
</li>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-stub
event="NONE"
filename="design-2-name"
id="design-2"
image="design-2-image"
notescount="1"
/>
<!---->
</li>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-stub
event="NONE"
filename="design-3-name"
id="design-3"
image="design-3-image"
notescount="0"
/>
<!---->
</li>
</ol>
</div>
<routerview-stub
name="default"
/>
</div>
`;
exports[`Design management index page designs renders designs list and header with upload button 1`] = `
<div>
<header
class="row-content-block border-top-0 p-2 d-flex"
>
<div
class="d-flex justify-content-between align-items-center w-100"
>
<designversiondropdown-stub />
<div
class="d-flex"
>
<glbutton-stub
class="mr-2 js-select-all"
variant="link"
>
Select all
</glbutton-stub>
<div>
<deletebutton-stub
buttonclass="btn-danger btn-inverted mr-2"
buttonvariant=""
>
Delete selected
<!---->
</deletebutton-stub>
</div>
<uploadbutton-stub />
</div>
</div>
</header>
<div
class="mt-4"
>
<designlist-stub
designs="design"
/>
<ol
class="list-unstyled row"
>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-stub
event="NONE"
filename="design-1-name"
id="design-1"
image="design-1-image"
notescount="0"
/>
<input
class="design-checkbox"
type="checkbox"
/>
</li>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-stub
event="NONE"
filename="design-2-name"
id="design-2"
image="design-2-image"
notescount="1"
/>
<input
class="design-checkbox"
type="checkbox"
/>
</li>
<li
class="col-md-6 col-lg-4 mb-3"
>
<design-stub
event="NONE"
filename="design-3-name"
id="design-3"
image="design-3-image"
notescount="0"
/>
<input
class="design-checkbox"
type="checkbox"
/>
</li>
</ol>
</div>
<router-view-stub />
<routerview-stub
name="default"
/>
</div>
`;
......@@ -32,7 +171,9 @@ exports[`Design management index page designs renders empty text 1`] = `
/>
</div>
<router-view-stub />
<routerview-stub
name="default"
/>
</div>
`;
......@@ -52,7 +193,9 @@ exports[`Design management index page designs renders error 1`] = `
</div>
</div>
<router-view-stub />
<routerview-stub
name="default"
/>
</div>
`;
......@@ -70,6 +213,8 @@ exports[`Design management index page designs renders loading icon 1`] = `
/>
</div>
<router-view-stub />
<routerview-stub
name="default"
/>
</div>
`;
......@@ -58,6 +58,10 @@ describe('Design management design index page', () => {
propsData: { id: '1' },
mocks: { $apollo },
});
wrapper.setData({
issueIid: '1',
});
}
function setDesign() {
......@@ -136,7 +140,7 @@ describe('Design management design index page', () => {
wrapper.vm.openCommentForm({ x: 0, y: 0 });
wrapper.vm.$nextTick(() => {
return wrapper.vm.$nextTick().then(() => {
expect(findDiscussionForm().exists()).toBe(true);
});
});
......@@ -155,15 +159,16 @@ describe('Design management design index page', () => {
comment: newComment,
});
wrapper.vm.$nextTick(() => {
findDiscussionForm().vm.$emit('submitForm');
return wrapper.vm
.$nextTick()
.then(() => {
findDiscussionForm().vm.$emit('submitForm');
expect(mutate).toHaveBeenCalledWith(mutationVariables);
const addNote = wrapper.vm.addImageDiffNote();
return addNote.then(() => {
expect(mutate).toHaveBeenCalledWith(mutationVariables);
return wrapper.vm.addImageDiffNote();
})
.then(() => {
expect(findDiscussionForm().exists()).toBe(false);
});
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueRouter from 'vue-router';
import Index from 'ee/design_management/pages/index.vue';
import UploadForm from 'ee/design_management/components/upload/form.vue';
import uploadDesignQuery from 'ee/design_management/graphql/mutations/uploadDesign.mutation.graphql';
import DesignDestroyer from 'ee/design_management/components/design_destroyer.vue';
const localVue = createLocalVue();
localVue.use(VueRouter);
......@@ -16,11 +16,50 @@ const router = new VueRouter({
],
});
const mockDesigns = [
{
id: 'design-1',
image: 'design-1-image',
filename: 'design-1-name',
event: 'NONE',
notesCount: 0,
},
{
id: 'design-2',
image: 'design-2-image',
filename: 'design-2-name',
event: 'NONE',
notesCount: 1,
},
{
id: 'design-3',
image: 'design-3-image',
filename: 'design-3-name',
event: 'NONE',
notesCount: 0,
},
];
const mockVersion = {
node: {
id: 'gid://gitlab/DesignManagement::Version/1',
},
};
describe('Design management index page', () => {
let mutate;
let vm;
let wrapper;
const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox');
const findSelectAllButton = () => wrapper.find('.js-select-all');
const findDeleteButton = () => wrapper.find('deletebutton-stub');
function createComponent(loading = false, designs = []) {
function createComponent({
loading = false,
designs = [],
allVersions = [],
createDesign = true,
} = {}) {
mutate = jest.fn(() => Promise.resolve());
const $apollo = {
queries: {
......@@ -34,64 +73,65 @@ describe('Design management index page', () => {
mutate,
};
vm = shallowMount(Index, {
wrapper = shallowMount(Index, {
sync: false,
mocks: { $apollo },
stubs: ['router-view'],
localVue,
router,
stubs: { DesignDestroyer },
});
vm.setData({
wrapper.setData({
designs,
allVersions,
issueIid: '1',
permissions: {
createDesign: true,
createDesign,
},
});
}
afterEach(() => {
vm.destroy();
wrapper.destroy();
});
describe('designs', () => {
it('renders loading icon', () => {
createComponent(true);
createComponent({ loading: true });
expect(vm.element).toMatchSnapshot();
expect(wrapper.element).toMatchSnapshot();
});
it('renders error', () => {
createComponent();
vm.setData({ error: true });
wrapper.setData({ error: true });
expect(vm.element).toMatchSnapshot();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
it('renders empty text', () => {
createComponent();
expect(vm.element).toMatchSnapshot();
});
it('renders designs list', () => {
createComponent(false, ['design']);
expect(vm.element).toMatchSnapshot();
expect(wrapper.element).toMatchSnapshot();
});
});
describe('upload form', () => {
it('hides upload form', () => {
createComponent();
it('renders designs list and header with upload button', () => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
expect(vm.find(UploadForm).exists()).toBe(false);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
it('renders upload form', () => {
createComponent(false, ['design']);
it('does not render toolbar when there is no permission', () => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion], createDesign: false });
expect(vm.find(UploadForm).exists()).toBe(true);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
});
......@@ -99,7 +139,7 @@ describe('Design management index page', () => {
it('calls apollo mutate', () => {
createComponent();
return vm.vm
return wrapper.vm
.onUploadDesign([
{
name: 'test',
......@@ -114,7 +154,7 @@ describe('Design management index page', () => {
variables: {
files: [{ name: 'test' }],
projectPath: '',
iid: null,
iid: '1',
},
update: expect.anything(),
optimisticResponse: {
......@@ -128,6 +168,8 @@ describe('Design management index page', () => {
image: '',
filename: 'test',
fullPath: '',
event: 'NONE',
notesCount: 0,
diffRefs: {
__typename: 'DiffRefs',
baseSha: '',
......@@ -154,15 +196,9 @@ describe('Design management index page', () => {
});
it('does not call apollo mutate if createDesign is false', () => {
createComponent();
createComponent({ createDesign: false });
vm.setData({
permissions: {
createDesign: false,
},
});
vm.vm.onUploadDesign([]);
wrapper.vm.onUploadDesign([]);
expect(mutate).not.toHaveBeenCalled();
});
......@@ -170,17 +206,96 @@ describe('Design management index page', () => {
it('sets isSaving', () => {
createComponent();
const uploadDesign = vm.vm.onUploadDesign([
const uploadDesign = wrapper.vm.onUploadDesign([
{
name: 'test',
},
]);
expect(vm.vm.isSaving).toBe(true);
expect(wrapper.vm.isSaving).toBe(true);
return uploadDesign.then(() => {
expect(vm.vm.isSaving).toBe(false);
expect(wrapper.vm.isSaving).toBe(false);
});
});
});
describe('on latest version', () => {
beforeEach(() => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
});
it('renders design checkboxes', () => {
expect(findDesignCheckboxes().length).toBe(mockDesigns.length);
});
it('renders a button with Select all text', () => {
expect(findSelectAllButton().exists()).toBe(true);
expect(findSelectAllButton().text()).toBe('Select all');
});
it('adds two designs to selected designs when their checkboxes are checked', () => {
findDesignCheckboxes()
.at(0)
.trigger('click');
findDesignCheckboxes()
.at(1)
.trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteButton().exists()).toBe(true);
expect(findSelectAllButton().text()).toBe('Deselect all');
findDeleteButton().vm.$emit('deleteSelectedDesigns');
const [{ variables }] = mutate.mock.calls[0];
expect(variables.filenames).toStrictEqual([
mockDesigns[0].filename,
mockDesigns[1].filename,
]);
});
});
it('adds all designs to selected designs when Select All button is clicked', () => {
findSelectAllButton().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteButton().props().hasSelectedDesigns).toBe(true);
expect(findSelectAllButton().text()).toBe('Deselect all');
expect(wrapper.vm.selectedDesigns).toEqual(mockDesigns.map(design => design.filename));
});
});
it('removes all designs from selected designs when at least one design was selected', () => {
findDesignCheckboxes()
.at(0)
.trigger('click');
findSelectAllButton().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteButton().props().hasSelectedDesigns).toBe(false);
expect(findSelectAllButton().text()).toBe('Select all');
expect(wrapper.vm.selectedDesigns).toEqual([]);
});
});
});
describe('on non-latest version', () => {
beforeEach(() => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
router.replace({
name: 'designs',
query: {
version: '2',
},
});
});
it('does not render design checkboxes', () => {
expect(findDesignCheckboxes().length).toBe(0);
});
it('does not render Select All button', () => {
expect(findSelectAllButton().exists()).toBe(false);
});
});
});
import {
extractCurrentDiscussion,
extractDiscussions,
} from 'ee/design_management/utils/design_management_utils';
describe('extractCurrentDiscussion', () => {
let discussions;
beforeEach(() => {
discussions = {
edges: [
{ node: { id: 101, payload: 'w' } },
{ node: { id: 102, payload: 'x' } },
{ node: { id: 103, payload: 'y' } },
{ node: { id: 104, payload: 'z' } },
],
};
});
it('finds the relevant discussion if it exists', () => {
const id = 103;
expect(extractCurrentDiscussion(discussions, id)).toEqual({
node: { id, payload: 'y' },
});
});
it('returns null if the relevant discussion does not exist', () => {
expect(extractCurrentDiscussion(discussions, 0)).not.toBeDefined();
});
});
describe('extractDiscussions', () => {
let discussions;
beforeEach(() => {
discussions = {
edges: [
{ node: { id: 1, notes: { edges: [{ node: 'a' }] } } },
{ node: { id: 2, notes: { edges: [{ node: 'b' }] } } },
{ node: { id: 3, notes: { edges: [{ node: 'c' }] } } },
{ node: { id: 4, notes: { edges: [{ node: 'd' }] } } },
],
};
});
it('discards the edges.node artefacts of GraphQL', () => {
expect(extractDiscussions(discussions)).toEqual([
{ id: 1, notes: ['a'] },
{ id: 2, notes: ['b'] },
{ id: 3, notes: ['c'] },
{ id: 4, notes: ['d'] },
]);
});
});
......@@ -5445,6 +5445,9 @@ msgstr ""
msgid "DesignManagement|An error occurred while loading designs. Please try again."
msgstr ""
msgid "DesignManagement|Are you sure you want to delete the selected designs?"
msgstr ""
msgid "DesignManagement|Could not add a new comment. Please try again"
msgstr ""
......@@ -5454,6 +5457,18 @@ msgstr ""
msgid "DesignManagement|Could not find design, please try again."
msgstr ""
msgid "DesignManagement|Delete"
msgstr ""
msgid "DesignManagement|Delete designs confirmation"
msgstr ""
msgid "DesignManagement|Delete selected"
msgstr ""
msgid "DesignManagement|Deselect all"
msgstr ""
msgid "DesignManagement|Error uploading a new design. Please try again"
msgstr ""
......@@ -5472,6 +5487,9 @@ msgstr ""
msgid "DesignManagement|Requested design version does not exist. Showing latest version instead"
msgstr ""
msgid "DesignManagement|Select all"
msgstr ""
msgid "DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again."
msgstr ""
......@@ -5481,6 +5499,9 @@ msgstr ""
msgid "DesignManagement|Upload and view the latest designs for this issue. Consistent and easy to find, so everyone is up to date."
msgstr ""
msgid "DesignManagement|We could not delete design(s). Please try again."
msgstr ""
msgid "Designs"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment