Commit b6327b58 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '34382-allow-the-ability-to-re-order-designs' into 'master'

Resolve "Allow the ability to re-order designs"

See merge request gitlab-org/gitlab!37686
parents 9b78128c ad2e62cc
......@@ -17,6 +17,11 @@ export default {
type: Boolean,
required: true,
},
isDraggingDesign: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -121,7 +126,7 @@ export default {
</slot>
<transition name="design-dropzone-fade">
<div
v-show="dragging"
v-show="dragging && !isDraggingDesign"
class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
>
<div v-show="!isDragDataValid" class="mw-50 text-center">
......
#import "../fragments/design_list.fragment.graphql"
mutation DesignManagementMove(
$id: DesignManagementDesignID!
$previous: DesignManagementDesignID
$next: DesignManagementDesignID
) {
designManagementMove(input: { id: $id, previous: $previous, next: $next }) {
designCollection {
designs {
nodes {
...DesignListItem
}
}
}
errors
}
}
......@@ -2,6 +2,7 @@
import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui';
import createFlash from '~/flash';
import { s__, sprintf } from '~/locale';
import VueDraggable from 'vuedraggable';
import UploadButton from '../components/upload/button.vue';
import DeleteButton from '../components/delete_button.vue';
import Design from '../components/list/item.vue';
......@@ -9,6 +10,7 @@ import DesignDestroyer from '../components/design_destroyer.vue';
import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue';
import DesignDropzone from '../components/upload/design_dropzone.vue';
import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql';
import moveDesignMutation from '../graphql/mutations/move_design.mutation.graphql';
import permissionsQuery from '../graphql/queries/design_permissions.query.graphql';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import allDesignsMixin from '../mixins/all_designs';
......@@ -16,13 +18,18 @@ import {
UPLOAD_DESIGN_ERROR,
EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
MOVE_DESIGN_ERROR,
designUploadSkippedWarning,
designDeletionError,
} from '../utils/error_messages';
import { updateStoreAfterUploadDesign } from '../utils/cache_update';
import {
updateStoreAfterUploadDesign,
updateDesignsOnStoreAfterReorder,
} from '../utils/cache_update';
import {
designUploadOptimisticResponse,
isValidDesignFile,
moveDesignOptimisticResponse,
} from '../utils/design_management_utils';
import { getFilename } from '~/lib/utils/file_upload';
import { DESIGNS_ROUTE_NAME } from '../router/constants';
......@@ -40,6 +47,7 @@ export default {
DesignVersionDropdown,
DeleteButton,
DesignDropzone,
VueDraggable,
},
mixins: [allDesignsMixin],
apollo: {
......@@ -61,6 +69,8 @@ export default {
},
filesToBeSaved: [],
selectedDesigns: [],
isDraggingDesign: false,
reorderedDesigns: null,
};
},
computed: {
......@@ -254,11 +264,48 @@ export default {
toggleOffPasteListener() {
document.removeEventListener('paste', this.onDesignPaste);
},
designMoveVariables(newIndex, element) {
const variables = {
id: element.id,
};
if (newIndex > 0) {
variables.previous = this.reorderedDesigns[newIndex - 1].id;
}
if (newIndex < this.reorderedDesigns.length - 1) {
variables.next = this.reorderedDesigns[newIndex + 1].id;
}
return variables;
},
reorderDesigns({ moved: { newIndex, element } }) {
this.$apollo
.mutate({
mutation: moveDesignMutation,
variables: this.designMoveVariables(newIndex, element),
update: (store, { data: { designManagementMove } }) => {
return updateDesignsOnStoreAfterReorder(
store,
designManagementMove,
this.projectQueryBody,
);
},
optimisticResponse: moveDesignOptimisticResponse(this.reorderedDesigns),
})
.catch(() => {
createFlash(MOVE_DESIGN_ERROR);
});
},
onDesignMove(designs) {
this.reorderedDesigns = designs;
},
},
beforeRouteUpdate(to, from, next) {
this.selectedDesigns = [];
next();
},
dragOptions: {
animation: 200,
ghostClass: 'gl-visibility-hidden',
},
};
</script>
......@@ -312,20 +359,35 @@ export default {
<gl-alert v-else-if="error" variant="danger" :dismissible="false">
{{ __('An error occurred while loading designs. Please try again.') }}
</gl-alert>
<ol v-else class="list-unstyled row">
<li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper">
<design-dropzone
:class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }"
:has-designs="hasDesigns"
@change="onUploadDesign"
/>
</li>
<li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-3 gl-mb-3">
<vue-draggable
v-else
:value="designs"
:disabled="!isLatestVersion"
v-bind="$options.dragOptions"
tag="ol"
draggable=".js-design-tile"
class="list-unstyled row"
@start="isDraggingDesign = true"
@end="isDraggingDesign = false"
@change="reorderDesigns"
@input="onDesignMove"
>
<li
v-for="design in designs"
:key="design.id"
class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
>
<design-dropzone
:has-designs="hasDesigns"
:is-dragging-design="isDraggingDesign"
@change="onExistingDesignDropzoneChange($event, design.filename)"
><design v-bind="design" :is-uploading="isDesignToBeSaved(design.filename)"
/></design-dropzone>
>
<design
v-bind="design"
:is-uploading="isDesignToBeSaved(design.filename)"
class="gl-bg-white"
/>
</design-dropzone>
<input
v-if="canSelectDesign(design.filename)"
......@@ -335,7 +397,17 @@ export default {
@change="changeSelectedDesigns(design.filename)"
/>
</li>
</ol>
<template #header>
<li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper">
<design-dropzone
:is-dragging-design="isDraggingDesign"
:class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }"
:has-designs="hasDesigns"
@change="onUploadDesign"
/>
</li>
</template>
</vue-draggable>
</div>
<router-view :key="$route.fullPath" />
</div>
......
......@@ -203,6 +203,15 @@ const addNewDesignToStore = (store, designManagementUpload, query) => {
});
};
const moveDesignInStore = (store, designManagementMove, query) => {
const data = store.readQuery(query);
data.project.issue.designCollection.designs = designManagementMove.designCollection.designs;
store.writeQuery({
...query,
data,
});
};
const onError = (data, message) => {
createFlash(message);
throw new Error(data.errors);
......@@ -264,3 +273,11 @@ export const updateStoreAfterUploadDesign = (store, data, query) => {
addNewDesignToStore(store, data, query);
}
};
export const updateDesignsOnStoreAfterReorder = (store, data, query) => {
if (hasErrors(data)) {
createFlash(data.errors[0]);
} else {
moveDesignInStore(store, data, query);
}
};
......@@ -85,7 +85,8 @@ export const designUploadOptimisticResponse = files => {
/**
* Generates optimistic response for a design upload mutation
* @param {Array<File>} files
* @param {Object} note
* @param {Object} position
*/
export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
......@@ -104,6 +105,27 @@ export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({
},
});
/**
* Generates optimistic response for a design upload mutation
* @param {Array} designs
*/
export const moveDesignOptimisticResponse = designs => ({
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Mutation',
designManagementMove: {
__typename: 'DesignManagementMovePayload',
designCollection: {
__typename: 'DesignCollection',
designs: {
__typename: 'DesignConnection',
nodes: designs,
},
},
errors: [],
},
});
const normalizeAuthor = author => ({
...author,
web_url: author.webUrl,
......
......@@ -40,6 +40,10 @@ export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __(
'You must upload a file with the same file name when dropping onto an existing design.',
);
export const MOVE_DESIGN_ERROR = __(
'Something went wrong when reordering designs. Please try again',
);
const MAX_SKIPPED_FILES_LISTINGS = 5;
const oneDesignSkippedMessage = filename =>
......
---
title: Resolve Allow the ability to re-order designs
merge_request: 37686
author:
type: added
......@@ -12019,6 +12019,16 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "EEIterationID",
"description": "Identifier of EE::Iteration",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INTERFACE",
"name": "Entry",
......@@ -16818,6 +16828,16 @@
},
"defaultValue": null
},
{
"name": "includeSubgroups",
"description": "Include issues belonging to subgroups.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": "false"
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
......@@ -35645,6 +35665,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "iteration",
"description": "Find an iteration",
"args": [
{
"name": "id",
"description": "Find an iteration by its ID",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "EEIterationID",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "Iteration",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "metadata",
"description": "Metadata about GitLab",
......@@ -202,6 +202,17 @@ Only the latest version of the designs can be deleted.
Deleted designs are not permanently lost; they can be
viewed by browsing previous versions.
## Reordering designs
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/34382) in GitLab 13.3.
You can change designs order with dragging design to the new position:
![Reorder designs](img/designs_reordering_v13_3.gif)
NOTE: **Note:**
You can reorder designs only on the latest version.
## Starting discussions on designs
When a design is uploaded, you can start a discussion by clicking on
......
......@@ -22639,6 +22639,9 @@ msgstr ""
msgid "Something went wrong trying to change the locked state of this %{issuableDisplayName}"
msgstr ""
msgid "Something went wrong when reordering designs. Please try again"
msgstr ""
msgid "Something went wrong when toggling the button"
msgstr ""
......
export const designListQueryResponse = {
data: {
project: {
id: '1',
issue: {
designCollection: {
designs: {
nodes: [
{
id: '1',
event: 'NONE',
filename: 'fox_1.jpg',
notesCount: 3,
image: 'image-1',
imageV432x230: 'image-1',
},
{
id: '2',
event: 'NONE',
filename: 'fox_2.jpg',
notesCount: 2,
image: 'image-2',
imageV432x230: 'image-2',
},
{
id: '3',
event: 'NONE',
filename: 'fox_3.jpg',
notesCount: 1,
image: 'image-3',
imageV432x230: 'image-3',
},
],
},
versions: {
nodes: [],
},
},
},
},
},
};
export const permissionsQueryResponse = {
data: {
project: {
id: '1',
issue: {
userPermissions: { createDesign: true },
},
},
},
};
export const reorderedDesigns = [
{
id: '2',
event: 'NONE',
filename: 'fox_2.jpg',
notesCount: 2,
image: 'image-2',
imageV432x230: 'image-2',
},
{
id: '1',
event: 'NONE',
filename: 'fox_1.jpg',
notesCount: 3,
image: 'image-1',
imageV432x230: 'image-1',
},
{
id: '3',
event: 'NONE',
filename: 'fox_3.jpg',
notesCount: 1,
image: 'image-3',
imageV432x230: 'image-3',
},
];
export const moveDesignMutationResponse = {
data: {
designManagementMove: {
designCollection: {
designs: {
nodes: [...reorderedDesigns],
},
},
errors: [],
},
},
};
export const moveDesignMutationResponseWithErrors = {
data: {
designManagementMove: {
designCollection: {
designs: {
nodes: [...reorderedDesigns],
},
},
errors: ['Houston, we have a problem'],
},
},
};
......@@ -22,14 +22,14 @@ exports[`Design management index page designs does not render toolbar when there
hasdesigns="true"
/>
</li>
<li
class="col-md-6 col-lg-3 gl-mb-3"
class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
>
<design-dropzone-stub
hasdesigns="true"
>
<design-stub
class="gl-bg-white"
event="NONE"
filename="design-1-name"
id="design-1"
......@@ -41,12 +41,13 @@ exports[`Design management index page designs does not render toolbar when there
<!---->
</li>
<li
class="col-md-6 col-lg-3 gl-mb-3"
class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
>
<design-dropzone-stub
hasdesigns="true"
>
<design-stub
class="gl-bg-white"
event="NONE"
filename="design-2-name"
id="design-2"
......@@ -58,12 +59,13 @@ exports[`Design management index page designs does not render toolbar when there
<!---->
</li>
<li
class="col-md-6 col-lg-3 gl-mb-3"
class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
>
<design-dropzone-stub
hasdesigns="true"
>
<design-stub
class="gl-bg-white"
event="NONE"
filename="design-3-name"
id="design-3"
......@@ -151,14 +153,14 @@ exports[`Design management index page designs renders designs list and header wi
hasdesigns="true"
/>
</li>
<li
class="col-md-6 col-lg-3 gl-mb-3"
class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
>
<design-dropzone-stub
hasdesigns="true"
>
<design-stub
class="gl-bg-white"
event="NONE"
filename="design-1-name"
id="design-1"
......@@ -173,12 +175,13 @@ exports[`Design management index page designs renders designs list and header wi
/>
</li>
<li
class="col-md-6 col-lg-3 gl-mb-3"
class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
>
<design-dropzone-stub
hasdesigns="true"
>
<design-stub
class="gl-bg-white"
event="NONE"
filename="design-2-name"
id="design-2"
......@@ -193,12 +196,13 @@ exports[`Design management index page designs renders designs list and header wi
/>
</li>
<li
class="col-md-6 col-lg-3 gl-mb-3"
class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
>
<design-dropzone-stub
hasdesigns="true"
>
<design-stub
class="gl-bg-white"
event="NONE"
filename="design-3-name"
id="design-3"
......@@ -296,7 +300,6 @@ exports[`Design management index page when has no designs renders design dropzon
class=""
/>
</li>
</ol>
</div>
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { createMockClient } from 'mock-apollo-client';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import VueDraggable from 'vuedraggable';
import Design from '~/design_management/components/list/item.vue';
import createRouter from '~/design_management/router';
import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql';
import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql';
import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql';
import createFlash from '~/flash';
import Index from '~/design_management/pages/index.vue';
import {
designListQueryResponse,
permissionsQueryResponse,
moveDesignMutationResponse,
reorderedDesigns,
moveDesignMutationResponseWithErrors,
} from '../mock_data/apollo_mock';
import { InMemoryCache } from 'apollo-cache-inmemory';
jest.mock('~/flash.js');
const localVue = createLocalVue();
localVue.use(VueApollo);
const router = createRouter();
localVue.use(VueRouter);
const designToMove = {
__typename: 'Design',
id: '2',
event: 'NONE',
filename: 'fox_2.jpg',
notesCount: 2,
image: 'image-2',
imageV432x230: 'image-2',
};
describe('Design management index page with Apollo mock', () => {
let wrapper;
let mockClient;
let apolloProvider;
let moveDesignHandler;
async function moveDesigns(localWrapper) {
await jest.runOnlyPendingTimers();
await localWrapper.vm.$nextTick();
localWrapper.find(VueDraggable).vm.$emit('input', reorderedDesigns);
localWrapper.find(VueDraggable).vm.$emit('change', {
moved: {
newIndex: 0,
element: designToMove,
},
});
}
const fragmentMatcher = { match: () => true };
const cache = new InMemoryCache({
fragmentMatcher,
addTypename: false,
});
const findDesigns = () => wrapper.findAll(Design);
function createComponent({
moveHandler = jest.fn().mockResolvedValue(moveDesignMutationResponse),
}) {
mockClient = createMockClient({ cache });
mockClient.setRequestHandler(
getDesignListQuery,
jest.fn().mockResolvedValue(designListQueryResponse),
);
mockClient.setRequestHandler(
permissionsQuery,
jest.fn().mockResolvedValue(permissionsQueryResponse),
);
moveDesignHandler = moveHandler;
mockClient.setRequestHandler(moveDesignMutation, moveDesignHandler);
apolloProvider = new VueApollo({
defaultClient: mockClient,
});
wrapper = shallowMount(Index, {
localVue,
apolloProvider,
router,
stubs: { VueDraggable },
});
}
afterEach(() => {
wrapper.destroy();
wrapper = null;
mockClient = null;
apolloProvider = null;
});
it('has a design with id 1 as a first one', async () => {
createComponent({});
await jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(findDesigns()).toHaveLength(3);
expect(
findDesigns()
.at(0)
.props('id'),
).toBe('1');
});
it('calls a mutation with correct parameters and reorders designs', async () => {
createComponent({});
await moveDesigns(wrapper);
expect(moveDesignHandler).toHaveBeenCalled();
await wrapper.vm.$nextTick();
expect(
findDesigns()
.at(0)
.props('id'),
).toBe('2');
});
it('displays flash if mutation had a recoverable error', async () => {
createComponent({
moveHandler: jest.fn().mockResolvedValue(moveDesignMutationResponseWithErrors),
});
await moveDesigns(wrapper);
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledWith('Houston, we have a problem');
});
it('displays flash if mutation had a non-recoverable error', async () => {
createComponent({
moveHandler: jest.fn().mockRejectedValue('Error'),
});
await moveDesigns(wrapper);
await jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong when reordering designs. Please try again',
);
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { ApolloMutation } from 'vue-apollo';
import VueDraggable from 'vuedraggable';
import VueRouter from 'vue-router';
import { GlEmptyState } from '@gitlab/ui';
import Index from '~/design_management/pages/index.vue';
......@@ -108,7 +109,7 @@ describe('Design management index page', () => {
mocks: { $apollo },
localVue,
router,
stubs: { DesignDestroyer, ApolloMutation, ...stubs },
stubs: { DesignDestroyer, ApolloMutation, VueDraggable, ...stubs },
attachToDocument: true,
provide: {
projectPath: 'project-path',
......
......@@ -8362,6 +8362,11 @@ mkdirp@0.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1:
dependencies:
minimist "0.0.8"
mock-apollo-client@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/mock-apollo-client/-/mock-apollo-client-0.4.0.tgz#556a6090b1816dbf07e51093b652aca84aee979e"
integrity sha512-cHznpkX8uUClkWWJMpgdDWzEgjacM85xt69S9gPLrssM8Vahas0QmEJkFUycrRQyBkaqxvRe58Bg3a5pOvj2zA==
moment-mini@^2.22.1:
version "2.22.1"
resolved "https://registry.yarnpkg.com/moment-mini/-/moment-mini-2.22.1.tgz#bc32d73e43a4505070be6b53494b17623183420d"
......
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