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: ...@@ -47,6 +47,8 @@ to be enabled:
when an issue is deleted. when an issue is deleted.
- Design Management - Design Management
[isn't supported by Geo](https://gitlab.com/groups/gitlab-org/-/epics/1633) yet. [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 ## The Design Management page
...@@ -77,6 +79,34 @@ to help summarize changes between versions. ...@@ -77,6 +79,34 @@ to help summarize changes between versions.
| Modified (in the selected version) | ![Design Modified](img/design_modified_v12_3.png) | | 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) | | 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 ## Adding annotations to designs
When a design image is displayed, you can add annotations to it by clicking on 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 { ...@@ -80,6 +80,7 @@ export default {
__typename: 'NoteEdge', __typename: 'NoteEdge',
node: createNote.note, node: createNote.note,
}); });
data.design.notesCount += 1;
store.writeQuery({ query: getDesignQuery, data }); 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 { ...@@ -25,7 +25,7 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
name: { filename: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -69,7 +69,7 @@ export default { ...@@ -69,7 +69,7 @@ export default {
<router-link <router-link
:to="{ :to="{
name: 'design', name: 'design',
params: { id: name }, params: { id: filename },
query: $route.query, query: $route.query,
}" }"
class="card cursor-pointer text-plain js-design-list-item design-list-item" class="card cursor-pointer text-plain js-design-list-item design-list-item"
...@@ -80,11 +80,13 @@ export default { ...@@ -80,11 +80,13 @@ export default {
<icon :name="icon.name" :size="18" :class="icon.classes" /> <icon :name="icon.name" :size="18" :class="icon.classes" />
</span> </span>
</div> </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>
<div class="card-footer d-flex w-100"> <div class="card-footer d-flex w-100">
<div class="d-flex flex-column str-truncated-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"> <span v-if="updatedAt" class="str-truncated-100">
{{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" /> {{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" />
</span> </span>
......
<script> <script>
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import { GlLoadingIcon } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import Pagination from './pagination.vue'; 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 { export default {
components: { components: {
GlLoadingIcon,
Icon, Icon,
Pagination, Pagination,
DeleteButton,
}, },
mixins: [timeagoMixin], mixins: [timeagoMixin],
props: { props: {
...@@ -17,6 +19,10 @@ export default { ...@@ -17,6 +19,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
isDeleting: {
type: Boolean,
required: true,
},
name: { name: {
type: String, type: String,
required: false, required: false,
...@@ -32,6 +38,39 @@ export default { ...@@ -32,6 +38,39 @@ export default {
required: false, required: false,
default: () => ({}), 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: { computed: {
updatedText() { updatedText() {
...@@ -40,6 +79,9 @@ export default { ...@@ -40,6 +79,9 @@ export default {
updated_by: this.updatedBy.name, updated_by: this.updatedBy.name,
}); });
}, },
canDeleteDesign() {
return this.permissions.createDesign;
},
}, },
}; };
</script> </script>
...@@ -61,5 +103,13 @@ export default { ...@@ -61,5 +103,13 @@ export default {
<small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small> <small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
</div> </div>
<pagination :id="id" class="ml-auto" /> <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> </header>
</template> </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/designList.fragment.graphql"
#import "../fragments/version.fragment.graphql"
query project($fullPath: ID!, $iid: String!, $atVersion: ID) { query project($fullPath: ID!, $iid: String!, $atVersion: ID) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
...@@ -15,8 +16,7 @@ query project($fullPath: ID!, $iid: String!, $atVersion: ID) { ...@@ -15,8 +16,7 @@ query project($fullPath: ID!, $iid: String!, $atVersion: ID) {
versions { versions {
edges { edges {
node { node {
id ...VersionListItem
sha
} }
} }
} }
......
import { propertyOf } from 'underscore';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import projectQuery from '../graphql/queries/project.query.graphql'; import projectQuery from '../graphql/queries/project.query.graphql';
...@@ -16,7 +17,13 @@ export default { ...@@ -16,7 +17,13 @@ export default {
atVersion: this.designsVersion, 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() { error() {
this.error = true; this.error = true;
}, },
......
import projectQuery from '../graphql/queries/project.query.graphql'; import projectQuery from '../graphql/queries/project.query.graphql';
import appDataQuery from '../graphql/queries/appData.query.graphql'; import appDataQuery from '../graphql/queries/appData.query.graphql';
import { findVersionId } from '../utils/design_management_utils';
export default { export default {
apollo: { apollo: {
...@@ -36,6 +37,13 @@ export default { ...@@ -36,6 +37,13 @@ export default {
? `gid://gitlab/DesignManagement::Version/${this.$route.query.version}` ? `gid://gitlab/DesignManagement::Version/${this.$route.query.version}`
: null; : 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() { data() {
return { return {
......
...@@ -9,6 +9,7 @@ import DesignImage from '../../components/image.vue'; ...@@ -9,6 +9,7 @@ import DesignImage from '../../components/image.vue';
import DesignOverlay from '../../components/design_overlay.vue'; import DesignOverlay from '../../components/design_overlay.vue';
import DesignDiscussion from '../../components/design_notes/design_discussion.vue'; import DesignDiscussion from '../../components/design_notes/design_discussion.vue';
import DesignReplyForm from '../../components/design_notes/design_reply_form.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 getDesignQuery from '../../graphql/queries/getDesign.query.graphql';
import createImageDiffNoteMutation from '../../graphql/mutations/createImageDiffNote.mutation.graphql'; import createImageDiffNoteMutation from '../../graphql/mutations/createImageDiffNote.mutation.graphql';
import { extractDiscussions } from '../../utils/design_management_utils'; import { extractDiscussions } from '../../utils/design_management_utils';
...@@ -18,6 +19,7 @@ export default { ...@@ -18,6 +19,7 @@ export default {
DesignImage, DesignImage,
DesignOverlay, DesignOverlay,
DesignDiscussion, DesignDiscussion,
DesignDestroyer,
Toolbar, Toolbar,
DesignReplyForm, DesignReplyForm,
GlLoadingIcon, GlLoadingIcon,
...@@ -142,6 +144,7 @@ export default { ...@@ -142,6 +144,7 @@ export default {
}, },
}; };
data.design.discussions.edges.push(newDiscussion); data.design.discussions.edges.push(newDiscussion);
data.design.notesCount += 1;
store.writeQuery({ query: getDesignQuery, data }); store.writeQuery({ query: getDesignQuery, data });
}, },
}) })
...@@ -193,12 +196,25 @@ export default { ...@@ -193,12 +196,25 @@ export default {
<gl-loading-icon v-if="isLoading" size="xl" class="align-self-center" /> <gl-loading-icon v-if="isLoading" size="xl" class="align-self-center" />
<template v-else> <template v-else>
<div class="d-flex flex-column w-100"> <div class="d-flex flex-column w-100">
<toolbar <design-destroyer
:id="id" :filenames="[design.filename]"
:name="design.filename" :project-path="projectPath"
:updated-at="design.updatedAt" :iid="issueIid"
:updated-by="design.updatedBy" @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"> <div class="d-flex flex-column w-100 h-100 mh-100 position-relative">
<design-image <design-image
:image="design.image" :image="design.image"
......
<script> <script>
import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; import { GlLoadingIcon, GlEmptyState, GlButton } from '@gitlab/ui';
import _ from 'underscore'; import _ from 'underscore';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { s__, sprintf } from '~/locale'; 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 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 uploadDesignMutation from '../graphql/mutations/uploadDesign.mutation.graphql';
import permissionsQuery from '../graphql/queries/permissions.query.graphql'; import permissionsQuery from '../graphql/queries/permissions.query.graphql';
import allDesignsMixin from '../mixins/all_designs';
import projectQuery from '../graphql/queries/project.query.graphql'; import projectQuery from '../graphql/queries/project.query.graphql';
import allDesignsMixin from '../mixins/all_designs';
const MAXIMUM_FILE_UPLOAD_LIMIT = 10; const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
export default { export default {
components: { components: {
GlLoadingIcon, GlLoadingIcon,
DesignList,
UploadForm,
UploadButton, UploadButton,
GlEmptyState, GlEmptyState,
GlButton,
Design,
DesignDestroyer,
DesignVersionDropdown,
DeleteButton,
}, },
mixins: [allDesignsMixin], mixins: [allDesignsMixin],
apollo: { apollo: {
...@@ -40,6 +45,7 @@ export default { ...@@ -40,6 +45,7 @@ export default {
createDesign: false, createDesign: false,
}, },
isSaving: false, isSaving: false,
selectedDesigns: [],
}; };
}, },
computed: { computed: {
...@@ -49,12 +55,29 @@ export default { ...@@ -49,12 +55,29 @@ export default {
canCreateDesign() { canCreateDesign() {
return this.permissions.createDesign; return this.permissions.createDesign;
}, },
showUploadForm() { showToolbar() {
return this.canCreateDesign && this.hasDesigns; return this.canCreateDesign && this.allVersions.length > 0;
}, },
hasDesigns() { hasDesigns() {
return this.designs.length > 0; 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: { methods: {
onUploadDesign(files) { onUploadDesign(files) {
...@@ -83,6 +106,8 @@ export default { ...@@ -83,6 +106,8 @@ export default {
image: '', image: '',
filename: file.name, filename: file.name,
fullPath: '', fullPath: '',
notesCount: 0,
event: 'NONE',
diffRefs: { diffRefs: {
__typename: 'DiffRefs', __typename: 'DiffRefs',
baseSha: '', baseSha: '',
...@@ -116,10 +141,7 @@ export default { ...@@ -116,10 +141,7 @@ export default {
hasUpload: true, hasUpload: true,
}, },
update: (store, { data: { designManagementUpload } }) => { update: (store, { data: { designManagementUpload } }) => {
const data = store.readQuery({ const data = store.readQuery(this.projectQueryBody);
query: projectQuery,
variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null },
});
const newDesigns = data.project.issue.designs.designs.edges.reduce((acc, design) => { const newDesigns = data.project.issue.designs.designs.edges.reduce((acc, design) => {
if (!acc.find(d => d.filename === design.node.filename)) { if (!acc.find(d => d.filename === design.node.filename)) {
...@@ -145,38 +167,26 @@ export default { ...@@ -145,38 +167,26 @@ export default {
...data.project.issue.designs.versions.edges, ...data.project.issue.designs.versions.edges,
]; ];
const newQueryData = { const updatedDesigns = {
project: { __typename: 'DesignCollection',
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 designs: {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings __typename: 'DesignConnection',
__typename: 'Project', edges: newDesigns.map(design => ({
id: '', __typename: 'DesignEdge',
issue: { node: design,
// 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', versions: {
designs: { __typename: 'DesignVersionConnection',
__typename: 'DesignCollection', edges: newVersions,
designs: {
__typename: 'DesignConnection',
edges: newDesigns.map(design => ({
__typename: 'DesignEdge',
node: design,
})),
},
versions: {
__typename: 'DesignVersionConnection',
edges: newVersions,
},
},
},
}, },
}; };
data.project.issue.designs = updatedDesigns;
store.writeQuery({ store.writeQuery({
query: projectQuery, ...this.projectQueryBody,
variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null }, data,
data: newQueryData,
}); });
}, },
optimisticResponse: { optimisticResponse: {
...@@ -200,25 +210,86 @@ export default { ...@@ -200,25 +210,86 @@ export default {
this.isSaving = false; 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> </script>
<template> <template>
<div> <div>
<upload-form <header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex">
v-if="showUploadForm" <div class="d-flex justify-content-between align-items-center w-100">
:can-upload-design="canCreateDesign" <design-version-dropdown />
:is-saving="isSaving" <div class="d-flex">
:all-versions="allVersions" <gl-button
@upload="onUploadDesign" 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"> <div class="mt-4">
<gl-loading-icon v-if="isLoading" size="md" /> <gl-loading-icon v-if="isLoading" size="md" />
<div v-else-if="error" class="alert alert-danger"> <div v-else-if="error" class="alert alert-danger">
{{ __('An error occurred while loading designs. Please try again.') }} {{ __('An error occurred while loading designs. Please try again.') }}
</div> </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 <gl-empty-state
v-else v-else
:title="s__('DesignManagement|The one place for your designs')" :title="s__('DesignManagement|The one place for your designs')"
......
import { s__ } from '~/locale';
import createFlash from '~/flash';
/** /**
* Returns formatted array that doesn't contain * Returns formatted array that doesn't contain
* `edges`->`node` nesting * `edges`->`node` nesting
...@@ -29,3 +32,50 @@ export const extractDiscussions = discussions => ...@@ -29,3 +32,50 @@ export const extractDiscussions = discussions =>
export const extractCurrentDiscussion = (discussions, id) => export const extractCurrentDiscussion = (discussions, id) =>
discussions.edges.find(({ node }) => node.id === 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 @@ ...@@ -11,6 +11,12 @@
right: $gl-padding; right: $gl-padding;
} }
.design-checkbox {
position: absolute;
top: $gl-padding;
left: 30px;
}
.image-notes { .image-notes {
overflow-y: scroll; overflow-y: scroll;
padding: 0 $gl-padding; 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', () => { ...@@ -73,7 +73,7 @@ describe('Design discussions component', () => {
it('hides reply placeholder and opens form on placeholder click', () => { it('hides reply placeholder and opens form on placeholder click', () => {
findReplyPlaceholder().trigger('click'); findReplyPlaceholder().trigger('click');
wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick().then(() => {
expect(findReplyPlaceholder().exists()).toBe(false); expect(findReplyPlaceholder().exists()).toBe(false);
expect(findReplyForm().exists()).toBe(true); expect(findReplyForm().exists()).toBe(true);
}); });
...@@ -85,16 +85,17 @@ describe('Design discussions component', () => { ...@@ -85,16 +85,17 @@ describe('Design discussions component', () => {
isFormRendered: true, isFormRendered: true,
}); });
wrapper.vm.$nextTick(() => { return wrapper.vm
findReplyForm().vm.$emit('submitForm'); .$nextTick()
.then(() => {
findReplyForm().vm.$emit('submitForm');
expect(mutate).toHaveBeenCalledWith(mutationVariables); expect(mutate).toHaveBeenCalledWith(mutationVariables);
const addComment = wrapper.vm.addDiscussionComment(); return wrapper.vm.addDiscussionComment();
})
return addComment.then(() => { .then(() => {
expect(findReplyForm().exists()).toBe(false); expect(findReplyForm().exists()).toBe(false);
}); });
});
}); });
}); });
...@@ -106,7 +106,7 @@ describe('Design overlay component', () => { ...@@ -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;'); 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', () => { ...@@ -16,7 +16,7 @@ describe('Design management list item component', () => {
router, router,
propsData: { propsData: {
id: 1, id: 1,
name: 'test', filename: 'test',
image: 'http://via.placeholder.com/300', image: 'http://via.placeholder.com/300',
event, event,
notesCount, notesCount,
......
...@@ -32,5 +32,16 @@ exports[`Design management toolbar component renders design and updated data 1`] ...@@ -32,5 +32,16 @@ exports[`Design management toolbar component renders design and updated data 1`]
class="ml-auto" class="ml-auto"
id="1" id="1"
/> />
<deletebutton-stub
buttonclass=""
buttonvariant="danger"
hasselecteddesigns="true"
>
<icon-stub
name="remove"
size="18"
/>
</deletebutton-stub>
</header> </header>
`; `;
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import Toolbar from 'ee/design_management/components/toolbar/index.vue'; import Toolbar from 'ee/design_management/components/toolbar/index.vue';
import DeleteButton from 'ee/design_management/components/delete_button.vue';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueRouter); localVue.use(VueRouter);
...@@ -20,7 +21,7 @@ const RouterLinkStub = { ...@@ -20,7 +21,7 @@ const RouterLinkStub = {
describe('Design management toolbar component', () => { describe('Design management toolbar component', () => {
let wrapper; let wrapper;
function createComponent(isLoading = false) { function createComponent(isLoading = false, createDesign = true, props) {
const updatedAt = new Date(); const updatedAt = new Date();
updatedAt.setHours(updatedAt.getHours() - 1); updatedAt.setHours(updatedAt.getHours() - 1);
...@@ -30,23 +31,38 @@ describe('Design management toolbar component', () => { ...@@ -30,23 +31,38 @@ describe('Design management toolbar component', () => {
router, router,
propsData: { propsData: {
id: '1', id: '1',
isLatestVersion: true,
isLoading, isLoading,
isDeleting: false,
name: 'test.jpg', name: 'test.jpg',
updatedAt: updatedAt.toString(), updatedAt: updatedAt.toString(),
updatedBy: { updatedBy: {
name: 'Test Name', name: 'Test Name',
}, },
...props,
}, },
stubs: { stubs: {
'router-link': RouterLinkStub, 'router-link': RouterLinkStub,
}, },
}); });
wrapper.setData({
permissions: {
createDesign,
},
});
} }
afterEach(() => {
wrapper.destroy();
});
it('renders design and updated data', () => { it('renders design and updated data', () => {
createComponent(); createComponent();
expect(wrapper.element).toMatchSnapshot(); return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
}); });
it('links back to designs list', () => { it('links back to designs list', () => {
...@@ -61,4 +77,37 @@ describe('Design management toolbar component', () => { ...@@ -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 // 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> <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 <div
class="mt-4" class="mt-4"
> >
<designlist-stub <ol
designs="design" 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> </div>
<router-view-stub /> <routerview-stub
name="default"
/>
</div> </div>
`; `;
...@@ -32,7 +171,9 @@ exports[`Design management index page designs renders empty text 1`] = ` ...@@ -32,7 +171,9 @@ exports[`Design management index page designs renders empty text 1`] = `
/> />
</div> </div>
<router-view-stub /> <routerview-stub
name="default"
/>
</div> </div>
`; `;
...@@ -52,7 +193,9 @@ exports[`Design management index page designs renders error 1`] = ` ...@@ -52,7 +193,9 @@ exports[`Design management index page designs renders error 1`] = `
</div> </div>
</div> </div>
<router-view-stub /> <routerview-stub
name="default"
/>
</div> </div>
`; `;
...@@ -70,6 +213,8 @@ exports[`Design management index page designs renders loading icon 1`] = ` ...@@ -70,6 +213,8 @@ exports[`Design management index page designs renders loading icon 1`] = `
/> />
</div> </div>
<router-view-stub /> <routerview-stub
name="default"
/>
</div> </div>
`; `;
...@@ -58,6 +58,10 @@ describe('Design management design index page', () => { ...@@ -58,6 +58,10 @@ describe('Design management design index page', () => {
propsData: { id: '1' }, propsData: { id: '1' },
mocks: { $apollo }, mocks: { $apollo },
}); });
wrapper.setData({
issueIid: '1',
});
} }
function setDesign() { function setDesign() {
...@@ -136,7 +140,7 @@ describe('Design management design index page', () => { ...@@ -136,7 +140,7 @@ describe('Design management design index page', () => {
wrapper.vm.openCommentForm({ x: 0, y: 0 }); wrapper.vm.openCommentForm({ x: 0, y: 0 });
wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick().then(() => {
expect(findDiscussionForm().exists()).toBe(true); expect(findDiscussionForm().exists()).toBe(true);
}); });
}); });
...@@ -155,15 +159,16 @@ describe('Design management design index page', () => { ...@@ -155,15 +159,16 @@ describe('Design management design index page', () => {
comment: newComment, comment: newComment,
}); });
wrapper.vm.$nextTick(() => { return wrapper.vm
findDiscussionForm().vm.$emit('submitForm'); .$nextTick()
.then(() => {
findDiscussionForm().vm.$emit('submitForm');
expect(mutate).toHaveBeenCalledWith(mutationVariables); expect(mutate).toHaveBeenCalledWith(mutationVariables);
const addNote = wrapper.vm.addImageDiffNote(); return wrapper.vm.addImageDiffNote();
})
return addNote.then(() => { .then(() => {
expect(findDiscussionForm().exists()).toBe(false); expect(findDiscussionForm().exists()).toBe(false);
}); });
});
}); });
}); });
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import Index from 'ee/design_management/pages/index.vue'; 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 uploadDesignQuery from 'ee/design_management/graphql/mutations/uploadDesign.mutation.graphql';
import DesignDestroyer from 'ee/design_management/components/design_destroyer.vue';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueRouter); localVue.use(VueRouter);
...@@ -16,11 +16,50 @@ const router = new 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', () => { describe('Design management index page', () => {
let mutate; 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()); mutate = jest.fn(() => Promise.resolve());
const $apollo = { const $apollo = {
queries: { queries: {
...@@ -34,64 +73,65 @@ describe('Design management index page', () => { ...@@ -34,64 +73,65 @@ describe('Design management index page', () => {
mutate, mutate,
}; };
vm = shallowMount(Index, { wrapper = shallowMount(Index, {
sync: false,
mocks: { $apollo }, mocks: { $apollo },
stubs: ['router-view'],
localVue, localVue,
router, router,
stubs: { DesignDestroyer },
}); });
vm.setData({ wrapper.setData({
designs, designs,
allVersions,
issueIid: '1',
permissions: { permissions: {
createDesign: true, createDesign,
}, },
}); });
} }
afterEach(() => { afterEach(() => {
vm.destroy(); wrapper.destroy();
}); });
describe('designs', () => { describe('designs', () => {
it('renders loading icon', () => { it('renders loading icon', () => {
createComponent(true); createComponent({ loading: true });
expect(vm.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
it('renders error', () => { it('renders error', () => {
createComponent(); 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', () => { it('renders empty text', () => {
createComponent(); createComponent();
expect(vm.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
});
it('renders designs list', () => {
createComponent(false, ['design']);
expect(vm.element).toMatchSnapshot();
}); });
});
describe('upload form', () => { it('renders designs list and header with upload button', () => {
it('hides upload form', () => { createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
createComponent();
expect(vm.find(UploadForm).exists()).toBe(false); return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
}); });
it('renders upload form', () => { it('does not render toolbar when there is no permission', () => {
createComponent(false, ['design']); 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', () => { ...@@ -99,7 +139,7 @@ describe('Design management index page', () => {
it('calls apollo mutate', () => { it('calls apollo mutate', () => {
createComponent(); createComponent();
return vm.vm return wrapper.vm
.onUploadDesign([ .onUploadDesign([
{ {
name: 'test', name: 'test',
...@@ -114,7 +154,7 @@ describe('Design management index page', () => { ...@@ -114,7 +154,7 @@ describe('Design management index page', () => {
variables: { variables: {
files: [{ name: 'test' }], files: [{ name: 'test' }],
projectPath: '', projectPath: '',
iid: null, iid: '1',
}, },
update: expect.anything(), update: expect.anything(),
optimisticResponse: { optimisticResponse: {
...@@ -128,6 +168,8 @@ describe('Design management index page', () => { ...@@ -128,6 +168,8 @@ describe('Design management index page', () => {
image: '', image: '',
filename: 'test', filename: 'test',
fullPath: '', fullPath: '',
event: 'NONE',
notesCount: 0,
diffRefs: { diffRefs: {
__typename: 'DiffRefs', __typename: 'DiffRefs',
baseSha: '', baseSha: '',
...@@ -154,15 +196,9 @@ describe('Design management index page', () => { ...@@ -154,15 +196,9 @@ describe('Design management index page', () => {
}); });
it('does not call apollo mutate if createDesign is false', () => { it('does not call apollo mutate if createDesign is false', () => {
createComponent(); createComponent({ createDesign: false });
vm.setData({ wrapper.vm.onUploadDesign([]);
permissions: {
createDesign: false,
},
});
vm.vm.onUploadDesign([]);
expect(mutate).not.toHaveBeenCalled(); expect(mutate).not.toHaveBeenCalled();
}); });
...@@ -170,17 +206,96 @@ describe('Design management index page', () => { ...@@ -170,17 +206,96 @@ describe('Design management index page', () => {
it('sets isSaving', () => { it('sets isSaving', () => {
createComponent(); createComponent();
const uploadDesign = vm.vm.onUploadDesign([ const uploadDesign = wrapper.vm.onUploadDesign([
{ {
name: 'test', name: 'test',
}, },
]); ]);
expect(vm.vm.isSaving).toBe(true); expect(wrapper.vm.isSaving).toBe(true);
return uploadDesign.then(() => { 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 "" ...@@ -5445,6 +5445,9 @@ msgstr ""
msgid "DesignManagement|An error occurred while loading designs. Please try again." msgid "DesignManagement|An error occurred while loading designs. Please try again."
msgstr "" 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" msgid "DesignManagement|Could not add a new comment. Please try again"
msgstr "" msgstr ""
...@@ -5454,6 +5457,18 @@ msgstr "" ...@@ -5454,6 +5457,18 @@ msgstr ""
msgid "DesignManagement|Could not find design, please try again." msgid "DesignManagement|Could not find design, please try again."
msgstr "" 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" msgid "DesignManagement|Error uploading a new design. Please try again"
msgstr "" msgstr ""
...@@ -5472,6 +5487,9 @@ msgstr "" ...@@ -5472,6 +5487,9 @@ msgstr ""
msgid "DesignManagement|Requested design version does not exist. Showing latest version instead" msgid "DesignManagement|Requested design version does not exist. Showing latest version instead"
msgstr "" msgstr ""
msgid "DesignManagement|Select all"
msgstr ""
msgid "DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again." msgid "DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again."
msgstr "" msgstr ""
...@@ -5481,6 +5499,9 @@ 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." msgid "DesignManagement|Upload and view the latest designs for this issue. Consistent and easy to find, so everyone is up to date."
msgstr "" msgstr ""
msgid "DesignManagement|We could not delete design(s). Please try again."
msgstr ""
msgid "Designs" msgid "Designs"
msgstr "" 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