Add headers to files in the tree list on merge requests
Closes https://gitlab.com/gitlab-org/gitlab-ce/issues/54807
This commit is contained in:
		
							parent
							
								
									1d2ef4c655
								
							
						
					
					
						commit
						12edecd002
					
				|  | @ -34,14 +34,18 @@ export default { | |||
| 
 | ||||
|       if (search === '') return this.renderTreeList ? this.tree : this.allBlobs; | ||||
| 
 | ||||
|       return this.allBlobs.filter(f => f.path.toLowerCase().indexOf(search) >= 0); | ||||
|     }, | ||||
|     rowDisplayTextKey() { | ||||
|       if (this.renderTreeList && this.search.trim() === '') { | ||||
|         return 'name'; | ||||
|       } | ||||
|       return this.allBlobs.reduce((acc, folder) => { | ||||
|         const tree = folder.tree.filter(f => f.path.toLowerCase().indexOf(search) >= 0); | ||||
| 
 | ||||
|       return 'path'; | ||||
|         if (tree.length) { | ||||
|           return acc.concat({ | ||||
|             ...folder, | ||||
|             tree, | ||||
|           }); | ||||
|         } | ||||
| 
 | ||||
|         return acc; | ||||
|       }, []); | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|  | @ -119,7 +123,7 @@ export default { | |||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class="tree-list-scroll"> | ||||
|     <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll"> | ||||
|       <template v-if="filteredTreeList.length"> | ||||
|         <file-row | ||||
|           v-for="file in filteredTreeList" | ||||
|  | @ -129,8 +133,6 @@ export default { | |||
|           :hide-extra-on-tree="true" | ||||
|           :extra-component="$options.FileRowStats" | ||||
|           :show-changed-icon="true" | ||||
|           :display-text-key="rowDisplayTextKey" | ||||
|           :should-truncate-start="true" | ||||
|           @toggleTreeOpen="toggleTreeOpen" | ||||
|           @clickFile="scrollToFile" | ||||
|         /> | ||||
|  | @ -148,3 +150,9 @@ export default { | |||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <style> | ||||
| .tree-list-blobs .file-row-name { | ||||
|   margin-left: 12px; | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -74,7 +74,24 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) = | |||
| export const getDiffFileByHash = state => fileHash => | ||||
|   state.diffFiles.find(file => file.file_hash === fileHash); | ||||
| 
 | ||||
| export const allBlobs = state => Object.values(state.treeEntries).filter(f => f.type === 'blob'); | ||||
| export const allBlobs = state => | ||||
|   Object.values(state.treeEntries) | ||||
|     .filter(f => f.type === 'blob') | ||||
|     .reduce((acc, file) => { | ||||
|       const { parentPath } = file; | ||||
| 
 | ||||
|       if (parentPath && !acc.some(f => f.path === parentPath)) { | ||||
|         acc.push({ | ||||
|           path: parentPath, | ||||
|           isHeader: true, | ||||
|           tree: [], | ||||
|         }); | ||||
|       } | ||||
| 
 | ||||
|       acc.find(f => f.path === parentPath).tree.push(file); | ||||
| 
 | ||||
|       return acc; | ||||
|     }, []); | ||||
| 
 | ||||
| export const diffFilesLength = state => state.diffFiles.length; | ||||
| 
 | ||||
|  |  | |||
|  | @ -318,6 +318,7 @@ export const generateTreeList = files => | |||
|               fileHash: file.file_hash, | ||||
|               addedLines: file.added_lines, | ||||
|               removedLines: file.removed_lines, | ||||
|               parentPath: parent ? `${parent.path}/` : '/', | ||||
|             }); | ||||
|           } else { | ||||
|             Object.assign(entry, { | ||||
|  |  | |||
|  | @ -72,6 +72,29 @@ export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3 | |||
|  */ | ||||
| export const truncateSha = sha => sha.substr(0, 8); | ||||
| 
 | ||||
| const ELLIPSIS_CHAR = '…'; | ||||
| export const truncatePathMiddleToLength = (text, maxWidth) => { | ||||
|   let returnText = text; | ||||
|   let ellipsisCount = 0; | ||||
| 
 | ||||
|   while (returnText.length >= maxWidth) { | ||||
|     const textSplit = returnText.split('/').filter(s => s !== ELLIPSIS_CHAR); | ||||
|     const middleIndex = Math.floor(textSplit.length / 2); | ||||
| 
 | ||||
|     returnText = textSplit | ||||
|       .slice(0, middleIndex) | ||||
|       .concat( | ||||
|         new Array(ellipsisCount + 1).fill().map(() => ELLIPSIS_CHAR), | ||||
|         textSplit.slice(middleIndex + 1), | ||||
|       ) | ||||
|       .join('/'); | ||||
| 
 | ||||
|     ellipsisCount += 1; | ||||
|   } | ||||
| 
 | ||||
|   return returnText; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Capitalizes first character | ||||
|  * | ||||
|  |  | |||
|  | @ -1,11 +1,13 @@ | |||
| <script> | ||||
| import Icon from '~/vue_shared/components/icon.vue'; | ||||
| import FileHeader from '~/vue_shared/components/file_row_header.vue'; | ||||
| import FileIcon from '~/vue_shared/components/file_icon.vue'; | ||||
| import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; | ||||
| 
 | ||||
| export default { | ||||
|   name: 'FileRow', | ||||
|   components: { | ||||
|     FileHeader, | ||||
|     FileIcon, | ||||
|     Icon, | ||||
|     ChangedFileIcon, | ||||
|  | @ -34,21 +36,10 @@ export default { | |||
|       required: false, | ||||
|       default: false, | ||||
|     }, | ||||
|     displayTextKey: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: 'name', | ||||
|     }, | ||||
|     shouldTruncateStart: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: false, | ||||
|     }, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       mouseOver: false, | ||||
|       truncateStart: 0, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|  | @ -60,7 +51,7 @@ export default { | |||
|     }, | ||||
|     levelIndentation() { | ||||
|       return { | ||||
|         marginLeft: `${this.level * 16}px`, | ||||
|         marginLeft: this.level ? `${this.level * 16}px` : null, | ||||
|       }; | ||||
|     }, | ||||
|     fileClass() { | ||||
|  | @ -71,14 +62,8 @@ export default { | |||
|         'is-open': this.file.opened, | ||||
|       }; | ||||
|     }, | ||||
|     outputText() { | ||||
|       const text = this.file[this.displayTextKey]; | ||||
| 
 | ||||
|       if (this.truncateStart === 0) { | ||||
|         return text; | ||||
|       } | ||||
| 
 | ||||
|       return `...${text.substring(this.truncateStart, text.length)}`; | ||||
|     childFilesLevel() { | ||||
|       return this.file.isHeader ? 0 : this.level + 1; | ||||
|     }, | ||||
|   }, | ||||
|   watch: { | ||||
|  | @ -92,15 +77,6 @@ export default { | |||
|     if (this.hasPathAtCurrentRoute()) { | ||||
|       this.scrollIntoView(true); | ||||
|     } | ||||
| 
 | ||||
|     if (this.shouldTruncateStart) { | ||||
|       const { scrollWidth, offsetWidth } = this.$refs.textOutput; | ||||
|       const textOverflow = scrollWidth - offsetWidth; | ||||
| 
 | ||||
|       if (textOverflow > 0) { | ||||
|         this.truncateStart = Math.ceil(textOverflow / 5) + 3; | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     toggleTreeOpen(path) { | ||||
|  | @ -156,7 +132,9 @@ export default { | |||
| 
 | ||||
| <template> | ||||
|   <div> | ||||
|     <file-header v-if="file.isHeader" :path="file.path" /> | ||||
|     <div | ||||
|       v-else | ||||
|       :class="fileClass" | ||||
|       class="file-row" | ||||
|       role="button" | ||||
|  | @ -175,7 +153,7 @@ export default { | |||
|             :size="16" | ||||
|           /> | ||||
|           <changed-file-icon v-else :file="file" :size="16" class="append-right-5" /> | ||||
|           {{ outputText }} | ||||
|           {{ file.name }} | ||||
|         </span> | ||||
|         <component | ||||
|           :is="extraComponent" | ||||
|  | @ -185,17 +163,15 @@ export default { | |||
|         /> | ||||
|       </div> | ||||
|     </div> | ||||
|     <template v-if="file.opened"> | ||||
|     <template v-if="file.opened || file.isHeader"> | ||||
|       <file-row | ||||
|         v-for="childFile in file.tree" | ||||
|         :key="childFile.key" | ||||
|         :file="childFile" | ||||
|         :level="level + 1" | ||||
|         :level="childFilesLevel" | ||||
|         :hide-extra-on-tree="hideExtraOnTree" | ||||
|         :extra-component="extraComponent" | ||||
|         :show-changed-icon="showChangedIcon" | ||||
|         :display-text-key="displayTextKey" | ||||
|         :should-truncate-start="shouldTruncateStart" | ||||
|         @toggleTreeOpen="toggleTreeOpen" | ||||
|         @clickFile="clickedFile" | ||||
|       /> | ||||
|  |  | |||
|  | @ -0,0 +1,25 @@ | |||
| <script> | ||||
| import { truncatePathMiddleToLength } from '~/lib/utils/text_utility'; | ||||
| 
 | ||||
| const MAX_PATH_LENGTH = 40; | ||||
| 
 | ||||
| export default { | ||||
|   props: { | ||||
|     path: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     truncatedPath() { | ||||
|       return truncatePathMiddleToLength(this.path, MAX_PATH_LENGTH); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div class="file-row-header bg-white sticky-top p-2 js-file-row-header"> | ||||
|     <span class="bold">{{ truncatedPath }}</span> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -0,0 +1,5 @@ | |||
| --- | ||||
| title: Add folder header to files in merge request tree list | ||||
| merge_request: | ||||
| author: | ||||
| type: changed | ||||
|  | @ -0,0 +1,37 @@ | |||
| // Jest Snapshot v1, https://goo.gl/fbAQLP | ||||
| 
 | ||||
| exports[`File row header component adds multiple ellipsises after 40 characters 1`] = ` | ||||
| <div | ||||
|   class="file-row-header bg-white sticky-top p-2 js-file-row-header" | ||||
| > | ||||
|   <span | ||||
|     class="bold" | ||||
|   > | ||||
|     app/assets/javascripts/…/…/diffs/notes | ||||
|   </span> | ||||
| </div> | ||||
| `; | ||||
| 
 | ||||
| exports[`File row header component renders file path 1`] = ` | ||||
| <div | ||||
|   class="file-row-header bg-white sticky-top p-2 js-file-row-header" | ||||
| > | ||||
|   <span | ||||
|     class="bold" | ||||
|   > | ||||
|     app/assets | ||||
|   </span> | ||||
| </div> | ||||
| `; | ||||
| 
 | ||||
| exports[`File row header component trucates path after 40 characters 1`] = ` | ||||
| <div | ||||
|   class="file-row-header bg-white sticky-top p-2 js-file-row-header" | ||||
| > | ||||
|   <span | ||||
|     class="bold" | ||||
|   > | ||||
|     app/assets/javascripts/merge_requests | ||||
|   </span> | ||||
| </div> | ||||
| `; | ||||
|  | @ -0,0 +1,36 @@ | |||
| import { shallowMount } from '@vue/test-utils'; | ||||
| import FileRowHeader from '~/vue_shared/components/file_row_header.vue'; | ||||
| 
 | ||||
| describe('File row header component', () => { | ||||
|   let vm; | ||||
| 
 | ||||
|   function createComponent(path) { | ||||
|     vm = shallowMount(FileRowHeader, { | ||||
|       propsData: { | ||||
|         path, | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     vm.destroy(); | ||||
|   }); | ||||
| 
 | ||||
|   it('renders file path', () => { | ||||
|     createComponent('app/assets'); | ||||
| 
 | ||||
|     expect(vm.element).toMatchSnapshot(); | ||||
|   }); | ||||
| 
 | ||||
|   it('trucates path after 40 characters', () => { | ||||
|     createComponent('app/assets/javascripts/merge_requests'); | ||||
| 
 | ||||
|     expect(vm.element).toMatchSnapshot(); | ||||
|   }); | ||||
| 
 | ||||
|   it('adds multiple ellipsises after 40 characters', () => { | ||||
|     createComponent('app/assets/javascripts/merge_requests/widget/diffs/notes'); | ||||
| 
 | ||||
|     expect(vm.element).toMatchSnapshot(); | ||||
|   }); | ||||
| }); | ||||
|  | @ -26,6 +26,8 @@ describe('Diffs tree list component', () => { | |||
|     store.state.diffs.removedLines = 20; | ||||
|     store.state.diffs.diffFiles.push('test'); | ||||
| 
 | ||||
|     localStorage.removeItem('mr_diff_tree_list'); | ||||
| 
 | ||||
|     vm = mountComponentWithStore(Component, { store }); | ||||
|   }); | ||||
| 
 | ||||
|  | @ -57,6 +59,7 @@ describe('Diffs tree list component', () => { | |||
|           removedLines: 0, | ||||
|           tempFile: true, | ||||
|           type: 'blob', | ||||
|           parentPath: 'app', | ||||
|         }, | ||||
|         app: { | ||||
|           key: 'app', | ||||
|  | @ -121,7 +124,7 @@ describe('Diffs tree list component', () => { | |||
|       vm.renderTreeList = false; | ||||
| 
 | ||||
|       vm.$nextTick(() => { | ||||
|         expect(vm.$el.querySelector('.file-row').textContent).toContain('app/index.js'); | ||||
|         expect(vm.$el.querySelector('.file-row').textContent).toContain('index.js'); | ||||
| 
 | ||||
|         done(); | ||||
|       }); | ||||
|  |  | |||
|  | @ -230,15 +230,30 @@ describe('Diffs Module Getters', () => { | |||
|       localState.treeEntries = { | ||||
|         file: { | ||||
|           type: 'blob', | ||||
|           path: 'file', | ||||
|           parentPath: '/', | ||||
|           tree: [], | ||||
|         }, | ||||
|         tree: { | ||||
|           type: 'tree', | ||||
|           path: 'tree', | ||||
|           parentPath: '/', | ||||
|           tree: [], | ||||
|         }, | ||||
|       }; | ||||
| 
 | ||||
|       expect(getters.allBlobs(localState)).toEqual([ | ||||
|         { | ||||
|           type: 'blob', | ||||
|           isHeader: true, | ||||
|           path: '/', | ||||
|           tree: [ | ||||
|             { | ||||
|               parentPath: '/', | ||||
|               path: 'file', | ||||
|               tree: [], | ||||
|               type: 'blob', | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|  |  | |||
|  | @ -502,6 +502,7 @@ describe('DiffsStoreUtils', () => { | |||
|               fileHash: 'test', | ||||
|               key: 'app/index.js', | ||||
|               name: 'index.js', | ||||
|               parentPath: 'app/', | ||||
|               path: 'app/index.js', | ||||
|               removedLines: 10, | ||||
|               tempFile: false, | ||||
|  | @ -522,6 +523,7 @@ describe('DiffsStoreUtils', () => { | |||
|                   fileHash: 'test', | ||||
|                   key: 'app/test/index.js', | ||||
|                   name: 'index.js', | ||||
|                   parentPath: 'app/test/', | ||||
|                   path: 'app/test/index.js', | ||||
|                   removedLines: 0, | ||||
|                   tempFile: true, | ||||
|  | @ -535,6 +537,7 @@ describe('DiffsStoreUtils', () => { | |||
|                   fileHash: 'test', | ||||
|                   key: 'app/test/filepathneedstruncating.js', | ||||
|                   name: 'filepathneedstruncating.js', | ||||
|                   parentPath: 'app/test/', | ||||
|                   path: 'app/test/filepathneedstruncating.js', | ||||
|                   removedLines: 0, | ||||
|                   tempFile: true, | ||||
|  | @ -548,6 +551,7 @@ describe('DiffsStoreUtils', () => { | |||
|         }, | ||||
|         { | ||||
|           key: 'package.json', | ||||
|           parentPath: '/', | ||||
|           path: 'package.json', | ||||
|           name: 'package.json', | ||||
|           type: 'blob', | ||||
|  |  | |||
|  | @ -135,4 +135,20 @@ describe('text_utility', () => { | |||
|       expect(textUtils.getFirstCharacterCapitalized(null)).toEqual(''); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('truncatePathMiddleToLength', () => { | ||||
|     it('does not truncate text', () => { | ||||
|       expect(textUtils.truncatePathMiddleToLength('app/test', 50)).toEqual('app/test'); | ||||
|     }); | ||||
| 
 | ||||
|     it('truncates middle of the path', () => { | ||||
|       expect(textUtils.truncatePathMiddleToLength('app/test/diff', 13)).toEqual('app/…/diff'); | ||||
|     }); | ||||
| 
 | ||||
|     it('truncates multiple times in the middle of the path', () => { | ||||
|       expect(textUtils.truncatePathMiddleToLength('app/test/merge_request/diff', 13)).toEqual( | ||||
|         'app/…/…/diff', | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ import FileRow from '~/vue_shared/components/file_row.vue'; | |||
| import { file } from 'spec/ide/helpers'; | ||||
| import mountComponent from '../../helpers/vue_mount_component_helper'; | ||||
| 
 | ||||
| describe('RepoFile', () => { | ||||
| describe('File row component', () => { | ||||
|   let vm; | ||||
| 
 | ||||
|   function createComponent(propsData) { | ||||
|  | @ -72,39 +72,16 @@ describe('RepoFile', () => { | |||
|     expect(vm.$el.querySelector('.file-row-name').style.marginLeft).toBe('32px'); | ||||
|   }); | ||||
| 
 | ||||
|   describe('outputText', () => { | ||||
|     beforeEach(done => { | ||||
|       createComponent({ | ||||
|         file: { | ||||
|           ...file(), | ||||
|           path: 'app/assets/index.js', | ||||
|         }, | ||||
|         level: 0, | ||||
|       }); | ||||
| 
 | ||||
|       vm.displayTextKey = 'path'; | ||||
| 
 | ||||
|       vm.$nextTick(done); | ||||
|   it('renders header for file', () => { | ||||
|     createComponent({ | ||||
|       file: { | ||||
|         isHeader: true, | ||||
|         path: 'app/assets', | ||||
|         tree: [], | ||||
|       }, | ||||
|       level: 0, | ||||
|     }); | ||||
| 
 | ||||
|     it('returns text if truncateStart is 0', done => { | ||||
|       vm.truncateStart = 0; | ||||
| 
 | ||||
|       vm.$nextTick(() => { | ||||
|         expect(vm.outputText).toBe('app/assets/index.js'); | ||||
| 
 | ||||
|         done(); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('returns text truncated at start', done => { | ||||
|       vm.truncateStart = 5; | ||||
| 
 | ||||
|       vm.$nextTick(() => { | ||||
|         expect(vm.outputText).toBe('...ssets/index.js'); | ||||
| 
 | ||||
|         done(); | ||||
|       }); | ||||
|     }); | ||||
|     expect(vm.$el.querySelector('.js-file-row-header')).not.toBe(null); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue