Commit b1b91aa0 authored by Phil Hughes's avatar Phil Hughes

Refactored multi-file data structure

This moves away from storing in a single array just to render the table.
It now stores in a multi-dimensional array/object type where each entry
in the array can have its own tree. This makes storing the data for
future feature a little easier as there is only one way to store the
data.

Previously to insert a directory the code had to insert the directory
& then the file at the right point in the array. Now the directory
can be inserted anywhere & then a file can be quickly added into this
directory.

The rendering is still done with a single array, but this is handled
through underscore. Underscore takes the array & then goes through
each item to flatten it into one. It is done this way to save changing
the markup away from table, keeping it as a table keeps it semantically
correct.
parent 3a7623fc
......@@ -92,7 +92,7 @@ const RepoEditor = {
},
blobRaw() {
if (Helper.monacoInstance && !this.isTree) {
if (Helper.monacoInstance) {
this.setupEditor();
}
},
......
<script>
import TimeAgoMixin from '../../vue_shared/mixins/timeago';
import timeAgoMixin from '../../vue_shared/mixins/timeago';
import eventHub from '../event_hub';
import repoMixin from '../mixins/repo_mixin';
const RepoFile = {
mixins: [TimeAgoMixin],
export default {
mixins: [
repoMixin,
timeAgoMixin,
],
props: {
file: {
type: Object,
required: true,
},
isMini: {
type: Boolean,
required: false,
default: false,
},
loading: {
type: Object,
required: false,
default() { return { tree: false }; },
},
hasFiles: {
type: Boolean,
required: false,
default: false,
},
activeFile: {
type: Object,
required: true,
},
},
computed: {
canShowFile() {
return !this.loading.tree || this.hasFiles;
},
fileIcon() {
const classObj = {
'fa-spinner fa-spin': this.file.loading,
[this.file.icon]: !this.file.loading,
'fa-folder-open': !this.file.loading && this.file.opened,
};
return classObj;
},
fileIndentation() {
return {
'margin-left': `${this.file.level * 10}px`,
};
},
activeFileClass() {
levelIndentation() {
return {
active: this.activeFile.url === this.file.url,
marginLeft: `${this.file.level * 16}px`,
};
},
},
methods: {
linkClicked(file) {
this.$emit('linkclicked', file);
eventHub.$emit('linkclicked', file);
},
},
};
export default RepoFile;
};
</script>
<template>
<tr
v-if="canShowFile"
<tr
class="file"
:class="activeFileClass"
@click.prevent="linkClicked(file)">
@click.stop="linkClicked(file)">
<td>
<i
class="fa fa-fw file-icon"
:class="fileIcon"
:style="fileIndentation"
aria-label="file icon">
:style="levelIndentation"
aria-hidden="true"
>
</i>
<a
:href="file.url"
class="repo-file-name"
:title="file.url">
{{file.name}}
>
{{ file.name }}
</a>
</td>
<template v-if="!isMini">
<td class="hidden-sm hidden-xs">
<div class="commit-message">
<a @click.stop :href="file.lastCommitUrl">
{{file.lastCommitMessage}}
<a
@click.stop
:href="file.lastCommit.url"
>
{{ file.lastCommit.message }}
</a>
</div>
</td>
<td class="hidden-xs text-right">
<span
class="commit-update"
:title="tooltipTitle(file.lastCommitUpdate)">
{{timeFormated(file.lastCommitUpdate)}}
<span :title="tooltipTitle(file.lastCommit.updatedAt)">
{{ timeFormated(file.lastCommit.updatedAt) }}
</span>
</td>
</template>
</tr>
</tr>
</template>
<script>
const RepoFileOptions = {
props: {
isMini: {
type: Boolean,
required: false,
default: false,
},
projectName: {
type: String,
required: true,
},
},
};
export default RepoFileOptions;
</script>
<template>
<tr v-if="isMini" class="repo-file-options">
<td>
<span class="title">{{projectName}}</span>
</td>
</tr>
</template>
<script>
const RepoLoadingFile = {
props: {
loading: {
type: Object,
required: false,
default: {},
},
hasFiles: {
type: Boolean,
required: false,
default: false,
},
isMini: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
showGhostLines() {
return this.loading.tree && !this.hasFiles;
},
},
import repoMixin from '../mixins/repo_mixin';
export default {
mixins: [
repoMixin,
],
methods: {
lineOfCode(n) {
return `skeleton-line-${n}`;
},
},
};
export default RepoLoadingFile;
};
</script>
<template>
<tr
v-if="showGhostLines"
class="loading-file">
<td>
<div
......@@ -64,7 +42,7 @@ export default RepoLoadingFile;
<td
v-if="!isMini"
class="hidden-xs">
<div class="animation-container animation-container-small">
<div class="animation-container animation-container-small animation-container-right">
<div
v-for="n in 6"
:key="n"
......
<script>
import RepoMixin from '../mixins/repo_mixin';
import eventHub from '../event_hub';
const RepoPreviousDirectory = {
export default {
props: {
prevUrl: {
type: String,
required: true,
},
},
mixins: [RepoMixin],
computed: {
colSpanCondition() {
return this.isMini ? undefined : 3;
},
},
methods: {
linkClicked(file) {
this.$emit('linkclicked', file);
eventHub.$emit('goToPreviousDirectoryClicked', file);
},
},
};
export default RepoPreviousDirectory;
};
</script>
<template>
<tr class="prev-directory">
<tr class="prev-directory">
<td
:colspan="colSpanCondition"
@click.prevent="linkClicked(prevUrl)">
<a :href="prevUrl">..</a>
class="table-cell"
@click.prevent="linkClicked(prevUrl)"
>
<a :href="prevUrl">...</a>
</td>
</tr>
</tr>
</template>
......@@ -2,8 +2,8 @@
import Service from '../services/repo_service';
import Helper from '../helpers/repo_helper';
import Store from '../stores/repo_store';
import eventHub from '../event_hub';
import RepoPreviousDirectory from './repo_prev_directory.vue';
import RepoFileOptions from './repo_file_options.vue';
import RepoFile from './repo_file.vue';
import RepoLoadingFile from './repo_loading_file.vue';
import RepoMixin from '../mixins/repo_mixin';
......@@ -11,21 +11,39 @@ import RepoMixin from '../mixins/repo_mixin';
export default {
mixins: [RepoMixin],
components: {
'repo-file-options': RepoFileOptions,
'repo-previous-directory': RepoPreviousDirectory,
'repo-file': RepoFile,
'repo-loading-file': RepoLoadingFile,
},
created() {
window.addEventListener('popstate', this.checkHistory);
},
destroyed() {
eventHub.$off('linkclicked', this.fileClicked);
eventHub.$off('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
window.removeEventListener('popstate', this.checkHistory);
},
mounted() {
eventHub.$on('linkclicked', this.fileClicked);
eventHub.$on('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
},
data: () => Store,
computed: {
flattendFiles() {
const map = (arr) => {
if (arr && arr.tree.length === 0) {
return [];
}
return _.map(arr.tree, a => [a, map(a)]);
};
return _.chain(this.files)
.map(arr => [arr, map(arr)])
.flatten()
.value();
},
},
methods: {
checkHistory() {
let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1);
......@@ -52,16 +70,17 @@ export default {
},
fileClicked(clickedFile, lineNumber) {
let file = clickedFile;
const file = clickedFile;
if (file.loading) return;
file.loading = true;
if (file.type === 'tree' && file.opened) {
file = Store.removeChildFilesOfTree(file);
file.loading = false;
Helper.setDirectoryToClosed(file);
Store.setActiveLine(lineNumber);
} else {
const openFile = Helper.getFileFromPath(file.url);
file.loading = true;
if (openFile) {
file.loading = false;
Store.setActiveFiles(openFile);
......@@ -92,38 +111,43 @@ export default {
<template>
<div id="sidebar" :class="{'sidebar-mini' : isMini}">
<table class="table">
<thead v-if="!isMini">
<thead>
<tr>
<th class="name">Name</th>
<th class="hidden-sm hidden-xs last-commit">Last commit</th>
<th class="hidden-xs last-update text-right">Last update</th>
<th
v-if="isMini"
class="repo-file-options title"
>
<strong class="clgray">
{{ projectName }}
</strong>
</th>
<template v-else>
<th class="name">
Name
</th>
<th class="hidden-sm hidden-xs last-commit">
Last commit
</th>
<th class="hidden-xs last-update text-right">
Last update
</th>
</template>
</tr>
</thead>
<tbody>
<repo-file-options
:is-mini="isMini"
:project-name="projectName"
/>
<repo-previous-directory
v-if="isRoot"
v-if="!isRoot"
:prev-url="prevURL"
@linkclicked="goToPreviousDirectoryClicked(prevURL)"/>
/>
<repo-loading-file
v-if="!flattendFiles.length && loading.tree"
v-for="n in 5"
:key="n"
:loading="loading"
:has-files="!!files.length"
:is-mini="isMini"
/>
<repo-file
v-for="file in files"
v-for="file in flattendFiles"
:key="file.id"
:file="file"
:is-mini="isMini"
@linkclicked="fileClicked(file)"
:is-tree="isTree"
:has-files="!!files.length"
:active-file="activeFile"
/>
</tbody>
</table>
......
......@@ -26,11 +26,13 @@ const RepoTab = {
},
methods: {
tabClicked: Store.setActiveFiles,
tabClicked(file) {
Store.setActiveFiles(file);
},
closeTab(file) {
if (file.changed) return;
this.$emit('tabclosed', file);
Store.removeFromOpenedFiles(file);
},
},
};
......@@ -39,10 +41,13 @@ export default RepoTab;
</script>
<template>
<li @click="tabClicked(tab)">
<a
href="#0"
class="close"
<li
:class="{ active : tab.active }"
@click="tabClicked(tab)"
>
<button
type="button"
class="close-btn"
@click.stop.prevent="closeTab(tab)"
:aria-label="closeLabel">
<i
......@@ -50,7 +55,7 @@ export default RepoTab;
:class="changedClass"
aria-hidden="true">
</i>
</a>
</button>
<a
href="#"
......@@ -59,5 +64,5 @@ export default RepoTab;
@click.prevent="tabClicked(tab)">
{{tab.name}}
</a>
</li>
</li>
</template>
<script>
import Store from '../stores/repo_store';
import RepoTab from './repo_tab.vue';
import RepoMixin from '../mixins/repo_mixin';
import Store from '../stores/repo_store';
import RepoTab from './repo_tab.vue';
import RepoMixin from '../mixins/repo_mixin';
const RepoTabs = {
export default {
mixins: [RepoMixin],
components: {
'repo-tab': RepoTab,
},
data: () => Store,
methods: {
tabClosed(file) {
Store.removeFromOpenedFiles(file);
},
},
};
export default RepoTabs;
};
</script>
<template>
<ul id="tabs">
<ul
id="tabs"
class="list-unstyled"
>
<repo-tab
v-for="tab in openedFiles"
:key="tab.id"
:tab="tab"
:class="{'active' : tab.active}"
@tabclosed="tabClosed"
/>
<li class="tabs-divider" />
</ul>
</ul>
</template>
import Vue from 'vue';
export default new Vue();
import { convertPermissionToBoolean } from '../../lib/utils/common_utils';
import Service from '../services/repo_service';
import Store from '../stores/repo_store';
import Flash from '../../flash';
......@@ -25,10 +26,6 @@ const RepoHelper = {
key: '',
isTree(data) {
return Object.hasOwnProperty.call(data, 'blobs');
},
Time: window.performance
&& window.performance.now
? window.performance
......@@ -59,12 +56,17 @@ const RepoHelper = {
setDirectoryOpen(tree, title) {
const file = tree;
if (!file) return undefined;
if (!file) return;
file.opened = true;
file.icon = 'fa-folder-open';
RepoHelper.updateHistoryEntry(file.url, title);
return file;
},
setDirectoryToClosed(entry) {
const dir = entry;
dir.opened = false;
dir.tree = [];
},
isRenderable() {
......@@ -81,63 +83,20 @@ const RepoHelper = {
.catch(RepoHelper.loadingError);
},
// when you open a directory you need to put the directory files under
// the directory... This will merge the list of the current directory and the new list.
getNewMergedList(inDirectory, currentList, newList) {
const newListSorted = newList.sort(this.compareFilesCaseInsensitive);
if (!inDirectory) return newListSorted;
const indexOfFile = currentList.findIndex(file => file.url === inDirectory.url);
if (!indexOfFile) return newListSorted;
return RepoHelper.mergeNewListToOldList(newListSorted, currentList, inDirectory, indexOfFile);
},
// within the get new merged list this does the merging of the current list of files
// and the new list of files. The files are never "in" another directory they just
// appear like they are because of the margin.
mergeNewListToOldList(newList, oldList, inDirectory, indexOfFile) {
newList.reverse().forEach((newFile) => {
const fileIndex = indexOfFile + 1;
const file = newFile;
file.level = inDirectory.level + 1;
oldList.splice(fileIndex, 0, file);
});
return oldList;
},
compareFilesCaseInsensitive(a, b) {
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
if (a.level > 0) return 0;
if (aName < bName) { return -1; }
if (aName > bName) { return 1; }
return 0;
},
isRoot(url) {
// the url we are requesting -> split by the project URL. Grab the right side.
const isRoot = !!url.split(Store.projectUrl)[1]
// remove the first "/"
.slice(1)
// split this by "/"
.split('/')
// remove the first two items of the array... usually /tree/master.
.slice(2)
// we want to know the length of the array.
// If greater than 0 not root.
.length;
return isRoot;
},
getContent(treeOrFile) {
let file = treeOrFile;
if (!Store.files.length) {
Store.loading.tree = true;
}
return Service.getContent()
.then((response) => {
const data = response.data;
if (response.headers && response.headers['page-title']) data.pageTitle = response.headers['page-title'];
if (response.headers && response.headers['is-root']) Store.isRoot = convertPermissionToBoolean(response.headers['is-root']);
Store.isTree = RepoHelper.isTree(data);
if (!Store.isTree) {
if (file && file.type === 'blob') {
if (!file) file = data;
Store.binary = data.binary;
......@@ -145,8 +104,7 @@ const RepoHelper = {
// file might be undefined
RepoHelper.setBinaryDataAsBase64(data);
Store.setViewToPreview();
} else if (!Store.isPreviewView()) {
if (!data.render_error) {
} else if (!Store.isPreviewView() && !data.render_error) {
Service.getRaw(data.raw_path)
.then((rawResponse) => {
Store.blobRaw = rawResponse.data;
......@@ -154,24 +112,20 @@ const RepoHelper = {
RepoHelper.setFile(data, file);
}).catch(RepoHelper.loadingError);
}
}
if (Store.isPreviewView()) {
RepoHelper.setFile(data, file);
}
} else {
Store.loading.tree = false;
RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name);
// if the file tree is empty
if (Store.files.length === 0) {
const parentURL = Service.blobURLtoParentTree(Service.url);
Service.url = parentURL;
RepoHelper.getContent();
}
if (!file) {
Store.files = this.dataToListOfFiles(data);
} else {
// it's a tree
if (!file) Store.isRoot = RepoHelper.isRoot(Service.url);
file = RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name);
const newDirectory = RepoHelper.dataToListOfFiles(data);
Store.addFilesToDirectory(file, Store.files, newDirectory);
file.tree = this.dataToListOfFiles(data, file.level + 1);
}
Store.prevURL = Service.blobURLtoParentTree(Service.url);
}
}).catch(RepoHelper.loadingError);
......@@ -190,57 +144,57 @@ const RepoHelper = {
Store.setActiveFiles(newFile);
},
serializeBlob(blob) {
const simpleBlob = RepoHelper.serializeRepoEntity('blob', blob);
simpleBlob.lastCommitMessage = blob.last_commit.message;
simpleBlob.lastCommitUpdate = blob.last_commit.committed_date;
simpleBlob.loading = false;
return simpleBlob;
serializeBlob(blob, level) {
return RepoHelper.serializeRepoEntity('blob', blob, level);
},
serializeTree(tree) {
return RepoHelper.serializeRepoEntity('tree', tree);
serializeTree(tree, level) {
return RepoHelper.serializeRepoEntity('tree', tree, level);
},
serializeSubmodule(submodule) {
return RepoHelper.serializeRepoEntity('submodule', submodule);
serializeSubmodule(submodule, level) {
return RepoHelper.serializeRepoEntity('submodule', submodule, level);
},
serializeRepoEntity(type, entity) {
serializeRepoEntity(type, entity, level = 0) {
const { url, name, icon, last_commit } = entity;
const returnObj = {
type,
name,
url,
level,
icon: `fa-${icon}`,
level: 0,
tree: [],
loading: false,
opened: false,
};
if (entity.last_commit) {
returnObj.lastCommitUrl = `${Store.projectUrl}/commit/${last_commit.id}`;
// eslint-disable-next-line camelcase
if (last_commit) {
returnObj.lastCommit = {
url: `${Store.projectUrl}/commit/${last_commit.id}`,
message: last_commit.message,
updatedAt: last_commit.committed_date,
};
} else {
returnObj.lastCommitUrl = '';
returnObj.lastCommit = {};
}
return returnObj;
},
scrollTabsRight() {
// wait for the transition. 0.1 seconds.
setTimeout(() => {
const tabs = document.getElementById('tabs');
if (!tabs) return;
tabs.scrollLeft = tabs.scrollWidth;
}, 200);
},
dataToListOfFiles(data) {
dataToListOfFiles(data, level) {
const { blobs, trees, submodules } = data;
return [
...blobs.map(blob => RepoHelper.serializeBlob(blob)),
...trees.map(tree => RepoHelper.serializeTree(tree)),
...submodules.map(submodule => RepoHelper.serializeSubmodule(submodule)),
...trees.map(tree => RepoHelper.serializeTree(tree, level)),
...blobs.map(blob => RepoHelper.serializeBlob(blob, level)),
...submodules.map(submodule => RepoHelper.serializeSubmodule(submodule, level)),
];
},
......
import $ from 'jquery';
import Vue from 'vue';
import { convertPermissionToBoolean } from '../lib/utils/common_utils';
import Service from './services/repo_service';
import Store from './stores/repo_store';
import Repo from './components/repo.vue';
......@@ -33,6 +34,7 @@ function setInitialStore(data) {
Store.onTopOfBranch = data.onTopOfBranch;
Store.newMrTemplateUrl = decodeURIComponent(data.newMrTemplateUrl);
Store.customBranchURL = decodeURIComponent(data.blobUrl);
Store.isRoot = convertPermissionToBoolean(data.root);
Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref');
Store.checkIsCommitable();
Store.setBranchHash();
......
......@@ -8,7 +8,6 @@ const RepoStore = {
canCommit: false,
onTopOfBranch: false,
editMode: false,
isTree: false,
isRoot: false,
prevURL: '',
projectId: '',
......@@ -72,10 +71,6 @@ const RepoStore = {
RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit;
},
addFilesToDirectory(inDirectory, currentList, newList) {
RepoStore.files = Helper.getNewMergedList(inDirectory, currentList, newList);
},
toggleRawPreview() {
RepoStore.activeFile.raw = !RepoStore.activeFile.raw;
RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source';
......@@ -129,30 +124,6 @@ const RepoStore = {
RepoStore.activeFileLabel = 'Display source';
},
removeChildFilesOfTree(tree) {
let foundTree = false;
const treeToClose = tree;
let canStopSearching = false;
RepoStore.files = RepoStore.files.filter((file) => {
const isItTheTreeWeWant = file.url === treeToClose.url;
// if it's the next tree
if (foundTree && file.type === 'tree' && !isItTheTreeWeWant && file.level === treeToClose.level) {
canStopSearching = true;
return true;
}
if (canStopSearching) return true;
if (isItTheTreeWeWant) foundTree = true;
if (foundTree) return file.level <= treeToClose.level;
return true;
});
treeToClose.opened = false;
treeToClose.icon = 'fa-folder';
return treeToClose;
},
removeFromOpenedFiles(file) {
if (file.type === 'tree') return;
let foundIndex;
......@@ -186,6 +157,7 @@ const RepoStore = {
if (openedFilesAlreadyExists) return;
openFile.changed = false;
openFile.active = true;
RepoStore.openedFiles.push(openFile);
},
......
......@@ -198,6 +198,13 @@ a {
height: 12px;
}
&.animation-container-right {
.skeleton-line-2 {
left: 0;
right: 150px;
}
}
&::before {
animation-duration: 1s;
animation-fill-mode: forwards;
......
......@@ -153,28 +153,13 @@
overflow-x: auto;
li {
animation: swipeRightAppear ease-in 0.1s;
animation-iteration-count: 1;
transform-origin: 0% 50%;
list-style-type: none;
position: relative;
background: $gray-normal;
display: inline-block;
padding: #{$gl-padding / 2} $gl-padding;
border-right: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
white-space: nowrap;
cursor: pointer;
&.remove {
animation: swipeRightDissapear ease-in 0.1s;
animation-iteration-count: 1;
transform-origin: 0% 50%;
a {
width: 0;
}
}
&.active {
background: $white-light;
border-bottom: none;
......@@ -182,17 +167,21 @@
a {
@include str-truncated(100px);
color: $black;
color: $gl-text-color;
vertical-align: middle;
text-decoration: none;
margin-right: 12px;
&.close {
width: auto;
font-size: 15px;
opacity: 1;
margin-right: -6px;
}
.close-btn {
position: absolute;
right: 8px;
top: 50%;
padding: 0;
background: none;
border: 0;
font-size: $gl-font-size;
transform: translateY(-50%);
}
.close-icon:hover {
......@@ -201,9 +190,6 @@
.close-icon,
.unsaved-icon {
float: right;
margin-top: 3px;
margin-left: 15px;
color: $gray-darkest;
}
......@@ -222,9 +208,7 @@
#repo-file-buttons {
background-color: $white-light;
border-bottom: 1px solid $white-normal;
padding: 5px 10px;
position: relative;
border-top: 1px solid $white-normal;
}
......@@ -287,37 +271,23 @@
overflow: auto;
}
table {
.table {
margin-bottom: 0;
}
tr {
animation: fadein 0.5s;
cursor: pointer;
&.repo-file-options td {
padding: 0;
border-top: none;
background: $gray-light;
.repo-file-options {
padding: 2px 16px;
width: 100%;
display: inline-block;
&:first-child {
border-top-left-radius: 2px;
}
.title {
display: inline-block;
font-size: 10px;
text-transform: uppercase;
font-weight: $gl-font-weight-bold;
color: $gray-darkest;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
padding: 2px 16px;
}
}
.file-icon {
......@@ -329,11 +299,13 @@
}
}
.file {
cursor: pointer;
}
a {
@include str-truncated(250px);
color: $almost-black;
display: inline-block;
vertical-align: middle;
}
}
}
......
......@@ -36,6 +36,7 @@ class Projects::TreeController < Projects::ApplicationController
format.json do
page_title @path.presence || _("Files"), @ref, @project.name_with_namespace
response.header['Is-Root'] = @path.empty?
# n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38261
Gitlab::GitalyClient.allow_n_plus_1_calls do
......
#repo{ data: { url: content_url,
#repo{ data: { root: @path.empty?.to_s,
url: content_url,
project_name: project.name,
refs_url: refs_project_path(project, format: :json),
project_url: project_path(project),
......
import Vue from 'vue';
import repoFileOptions from '~/repo/components/repo_file_options.vue';
describe('RepoFileOptions', () => {
const projectName = 'projectName';
function createComponent(propsData) {
const RepoFileOptions = Vue.extend(repoFileOptions);
return new RepoFileOptions({
propsData,
}).$mount();
}
it('renders the title and new file/folder buttons if isMini is true', () => {
const vm = createComponent({
isMini: true,
projectName,
});
expect(vm.$el.classList.contains('repo-file-options')).toBeTruthy();
expect(vm.$el.querySelector('.title').textContent).toEqual(projectName);
});
it('does not render if isMini is false', () => {
const vm = createComponent({
isMini: false,
projectName,
});
expect(vm.$el.innerHTML).toBeFalsy();
});
});
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