mirror of
https://github.com/nasa/openmct.git
synced 2025-05-02 16:53:24 +00:00
Master 2.1.1 (#5858)
* Update version * Don't delete annotations if there aren't any (#5829) * don't delete annotations if there aren't any * add test and align playwright-test * align core with test * added annotation describing test * Add `aria-label` for time conductor history button (#5830) * [Overlay Plot] Inspector series and legend sync fix (#5835) * fixed overlay plots to react to series removals correctly, added alias visual to elements pool aliased items * Keep transaction open on failed editor save (#5840) * do not end a transaction on a failed editor save * add unit tests for successful editor save and unsuccessful editor save * If no matching tags, do not attempt tag search (#5839) * do not attempt search if no matching tags * fix timing on test * commit again in hopes that github will run checks * add back null tag check * add some better documentation to tests Co-authored-by: Andrew Henry <akhenry@gmail.com> * Update version for master Co-authored-by: Scott Bell <scott@traclabs.com> Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com> Co-authored-by: Jamie V <jamie.j.vigliotta@nasa.gov> Co-authored-by: David Tsay <3614296+davetsay@users.noreply.github.com> Co-authored-by: Andrew Henry <akhenry@gmail.com>
This commit is contained in:
parent
026eb86f5f
commit
cb8e09c9f9
@ -27,7 +27,7 @@ This test suite is dedicated to tests which verify the basic operations surround
|
|||||||
const { test, expect } = require('../../../../baseFixtures');
|
const { test, expect } = require('../../../../baseFixtures');
|
||||||
const { createDomainObjectWithDefaults } = require('../../../../appActions');
|
const { createDomainObjectWithDefaults } = require('../../../../appActions');
|
||||||
|
|
||||||
test.describe('Notebook Network Request Inspection @couchdb', () => {
|
test.describe('Notebook Tests with CouchDB @couchdb', () => {
|
||||||
let testNotebook;
|
let testNotebook;
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
//Navigate to baseURL
|
//Navigate to baseURL
|
||||||
@ -221,6 +221,45 @@ test.describe('Notebook Network Request Inspection @couchdb', () => {
|
|||||||
|
|
||||||
expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(4);
|
expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Search tests', async ({ page }) => {
|
||||||
|
test.info().annotations.push({
|
||||||
|
type: 'issue',
|
||||||
|
description: 'https://github.com/akhenry/openmct-yamcs/issues/69'
|
||||||
|
});
|
||||||
|
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
||||||
|
await page.locator('[aria-label="Notebook Entry Input"]').click();
|
||||||
|
await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`);
|
||||||
|
await page.locator('[aria-label="Notebook Entry Input"]').press('Enter');
|
||||||
|
|
||||||
|
// Add three tags
|
||||||
|
await page.hover(`button:has-text("Add Tag")`);
|
||||||
|
await page.locator(`button:has-text("Add Tag")`).click();
|
||||||
|
await page.locator('[placeholder="Type to select tag"]').click();
|
||||||
|
await page.locator('[aria-label="Autocomplete Options"] >> text=Science').click();
|
||||||
|
await page.waitForSelector('[aria-label="Tag"]:has-text("Science")');
|
||||||
|
|
||||||
|
await page.hover(`button:has-text("Add Tag")`);
|
||||||
|
await page.locator(`button:has-text("Add Tag")`).click();
|
||||||
|
await page.locator('[placeholder="Type to select tag"]').click();
|
||||||
|
await page.locator('[aria-label="Autocomplete Options"] >> text=Drilling').click();
|
||||||
|
await page.waitForSelector('[aria-label="Tag"]:has-text("Drilling")');
|
||||||
|
|
||||||
|
await page.hover(`button:has-text("Add Tag")`);
|
||||||
|
await page.locator(`button:has-text("Add Tag")`).click();
|
||||||
|
await page.locator('[placeholder="Type to select tag"]').click();
|
||||||
|
await page.locator('[aria-label="Autocomplete Options"] >> text=Driving').click();
|
||||||
|
await page.waitForSelector('[aria-label="Tag"]:has-text("Driving")');
|
||||||
|
|
||||||
|
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
|
||||||
|
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Sc');
|
||||||
|
await expect(page.locator('[aria-label="Search Result"]').first()).toContainText("Science");
|
||||||
|
await expect(page.locator('[aria-label="Search Result"]').first()).not.toContainText("Driving");
|
||||||
|
|
||||||
|
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
|
||||||
|
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Xq');
|
||||||
|
await expect(page.locator('text=No results found')).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to reduce indeterminism of browser requests by only returning fetch requests.
|
// Try to reduce indeterminism of browser requests by only returning fetch requests.
|
||||||
|
@ -39,7 +39,7 @@ async function createNotebookAndEntry(page, iterations = 1) {
|
|||||||
createDomainObjectWithDefaults(page, { type: 'Notebook' });
|
createDomainObjectWithDefaults(page, { type: 'Notebook' });
|
||||||
|
|
||||||
for (let iteration = 0; iteration < iterations; iteration++) {
|
for (let iteration = 0; iteration < iterations; iteration++) {
|
||||||
// Click text=To start a new entry, click here or drag and drop any object
|
// Create an entry
|
||||||
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
||||||
const entryLocator = `[aria-label="Notebook Entry Input"] >> nth = ${iteration}`;
|
const entryLocator = `[aria-label="Notebook Entry Input"] >> nth = ${iteration}`;
|
||||||
await page.locator(entryLocator).click();
|
await page.locator(entryLocator).click();
|
||||||
@ -116,7 +116,7 @@ test.describe('Tagging in Notebooks @addInit', () => {
|
|||||||
|
|
||||||
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
|
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
|
||||||
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Xq');
|
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Xq');
|
||||||
await expect(page.locator('[aria-label="Search Result"]')).toBeHidden();
|
await expect(page.locator('text=No results found')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Can delete tags', async ({ page }) => {
|
test('Can delete tags', async ({ page }) => {
|
||||||
@ -133,6 +133,27 @@ test.describe('Tagging in Notebooks @addInit', () => {
|
|||||||
await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving");
|
await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Can delete entries without tags', async ({ page }) => {
|
||||||
|
test.info().annotations.push({
|
||||||
|
type: 'issue',
|
||||||
|
description: 'https://github.com/nasa/openmct/issues/5823'
|
||||||
|
});
|
||||||
|
|
||||||
|
await createNotebookEntryAndTags(page);
|
||||||
|
|
||||||
|
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
||||||
|
const entryLocator = `[aria-label="Notebook Entry Input"] >> nth = 1`;
|
||||||
|
await page.locator(entryLocator).click();
|
||||||
|
await page.locator(entryLocator).fill(`An entry without tags`);
|
||||||
|
await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').press('Enter');
|
||||||
|
|
||||||
|
await page.hover('[aria-label="Notebook Entry Input"] >> nth=1');
|
||||||
|
await page.locator('button[title="Delete this entry"]').last().click();
|
||||||
|
await expect(page.locator('text=This action will permanently delete this entry. Do you wish to continue?')).toBeVisible();
|
||||||
|
await page.locator('button:has-text("Ok")').click();
|
||||||
|
await expect(page.locator('text=This action will permanently delete this entry. Do you wish to continue?')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
test('Can delete objects with tags and neither return in search', async ({ page }) => {
|
test('Can delete objects with tags and neither return in search', async ({ page }) => {
|
||||||
await createNotebookEntryAndTags(page);
|
await createNotebookEntryAndTags(page);
|
||||||
// Delete Notebook
|
// Delete Notebook
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openmct",
|
"name": "openmct",
|
||||||
"version": "2.1.1-SNAPSHOT",
|
"version": "2.1.2",
|
||||||
"description": "The Open MCT core platform",
|
"description": "The Open MCT core platform",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/eslint-parser": "7.18.9",
|
"@babel/eslint-parser": "7.18.9",
|
||||||
@ -51,7 +51,7 @@
|
|||||||
"moment-timezone": "0.5.37",
|
"moment-timezone": "0.5.37",
|
||||||
"nyc": "15.1.0",
|
"nyc": "15.1.0",
|
||||||
"painterro": "1.2.78",
|
"painterro": "1.2.78",
|
||||||
"playwright-core": "1.26.1",
|
"playwright-core": "1.25.2",
|
||||||
"plotly.js-basic-dist": "2.14.0",
|
"plotly.js-basic-dist": "2.14.0",
|
||||||
"plotly.js-gl2d-dist": "2.14.0",
|
"plotly.js-gl2d-dist": "2.14.0",
|
||||||
"printj": "1.3.1",
|
"printj": "1.3.1",
|
||||||
|
@ -63,10 +63,9 @@ export default class Editor extends EventEmitter {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
this.editing = false;
|
this.editing = false;
|
||||||
this.emit('isEditing', false);
|
this.emit('isEditing', false);
|
||||||
|
this.openmct.objects.endTransaction();
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
throw error;
|
throw error;
|
||||||
}).finally(() => {
|
|
||||||
this.openmct.objects.endTransaction();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
80
src/api/EditorSpec.js
Normal file
80
src/api/EditorSpec.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/*****************************************************************************
|
||||||
|
* Open MCT, Copyright (c) 2014-2022, United States Government
|
||||||
|
* as represented by the Administrator of the National Aeronautics and Space
|
||||||
|
* Administration. All rights reserved.
|
||||||
|
*
|
||||||
|
* Open MCT is licensed under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*
|
||||||
|
* Open MCT includes source code licensed under additional open source
|
||||||
|
* licenses. See the Open Source Licenses file (LICENSES.md) included with
|
||||||
|
* this source code distribution or the Licensing information page available
|
||||||
|
* at runtime from the About dialog for additional information.
|
||||||
|
*****************************************************************************/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createOpenMct, resetApplicationState
|
||||||
|
} from '../utils/testing';
|
||||||
|
|
||||||
|
describe('The Editor API', () => {
|
||||||
|
let openmct;
|
||||||
|
|
||||||
|
beforeEach((done) => {
|
||||||
|
openmct = createOpenMct();
|
||||||
|
openmct.on('start', done);
|
||||||
|
|
||||||
|
spyOn(openmct.objects, 'endTransaction');
|
||||||
|
|
||||||
|
openmct.startHeadless();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
return resetApplicationState(openmct);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a transaction on edit', () => {
|
||||||
|
expect(
|
||||||
|
openmct.objects.isTransactionActive()
|
||||||
|
).toBeFalse();
|
||||||
|
openmct.editor.edit();
|
||||||
|
expect(
|
||||||
|
openmct.objects.isTransactionActive()
|
||||||
|
).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes an open transaction on successful save', async () => {
|
||||||
|
spyOn(openmct.objects, 'getActiveTransaction')
|
||||||
|
.and.returnValue({
|
||||||
|
commit: () => Promise.resolve(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
openmct.editor.edit();
|
||||||
|
await openmct.editor.save();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
openmct.objects.endTransaction
|
||||||
|
).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not close an open transaction on failed save', async () => {
|
||||||
|
spyOn(openmct.objects, 'getActiveTransaction')
|
||||||
|
.and.returnValue({
|
||||||
|
commit: () => Promise.reject()
|
||||||
|
});
|
||||||
|
|
||||||
|
openmct.editor.edit();
|
||||||
|
await openmct.editor.save().catch(() => {});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
openmct.objects.endTransaction
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
@ -346,6 +346,10 @@ export default class AnnotationAPI extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
async searchForTags(query, abortController) {
|
async searchForTags(query, abortController) {
|
||||||
const matchingTagKeys = this.#getMatchingTags(query);
|
const matchingTagKeys = this.#getMatchingTags(query);
|
||||||
|
if (!matchingTagKeys.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const searchResults = (await Promise.all(this.openmct.objects.search(matchingTagKeys, abortController, this.openmct.objects.SEARCH_TYPES.TAGS))).flat();
|
const searchResults = (await Promise.all(this.openmct.objects.search(matchingTagKeys, abortController, this.openmct.objects.SEARCH_TYPES.TAGS))).flat();
|
||||||
const filteredDeletedResults = searchResults.filter((result) => {
|
const filteredDeletedResults = searchResults.filter((result) => {
|
||||||
return !(result._deleted);
|
return !(result._deleted);
|
||||||
|
@ -185,5 +185,10 @@ describe("The Annotation API", () => {
|
|||||||
expect(results).toBeDefined();
|
expect(results).toBeDefined();
|
||||||
expect(results.length).toEqual(1);
|
expect(results.length).toEqual(1);
|
||||||
});
|
});
|
||||||
|
it("returns no tags for empty search", async () => {
|
||||||
|
const results = await openmct.annotation.searchForTags('q');
|
||||||
|
expect(results).toBeDefined();
|
||||||
|
expect(results.length).toEqual(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -515,7 +515,9 @@ export default {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
removeAnnotations(entryId) {
|
removeAnnotations(entryId) {
|
||||||
this.openmct.annotation.deleteAnnotations(this.notebookAnnotations[entryId]);
|
if (this.notebookAnnotations[entryId]) {
|
||||||
|
this.openmct.annotation.deleteAnnotations(this.notebookAnnotations[entryId]);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
checkEntryPos(entry) {
|
checkEntryPos(entry) {
|
||||||
const entryPos = getEntryPosById(entry.id, this.domainObject, this.selectedSection, this.selectedPage);
|
const entryPos = getEntryPosById(entry.id, this.domainObject, this.selectedSection, this.selectedPage);
|
||||||
|
@ -90,6 +90,10 @@ class CouchSearchProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
searchForTags(tagsArray, abortSignal) {
|
searchForTags(tagsArray, abortSignal) {
|
||||||
|
if (!tagsArray || !tagsArray.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const filter = {
|
const filter = {
|
||||||
"selector": {
|
"selector": {
|
||||||
"$and": [
|
"$and": [
|
||||||
|
@ -442,7 +442,8 @@ export default {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
removeSeries(plotSeries) {
|
removeSeries(plotSeries, index) {
|
||||||
|
this.seriesModels.splice(index, 1);
|
||||||
this.checkSameRangeValue();
|
this.checkSameRangeValue();
|
||||||
this.stopListening(plotSeries);
|
this.stopListening(plotSeries);
|
||||||
},
|
},
|
||||||
|
@ -102,8 +102,8 @@ export default class Collection extends Model {
|
|||||||
throw new Error('model not found in collection.');
|
throw new Error('model not found in collection.');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('remove', model, index);
|
|
||||||
this.models.splice(index, 1);
|
this.models.splice(index, 1);
|
||||||
|
this.emit('remove', model, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(model) {
|
destroy(model) {
|
||||||
|
@ -26,6 +26,7 @@
|
|||||||
>
|
>
|
||||||
<div class="c-menu-button c-ctrl-wrapper c-ctrl-wrapper--menus-left">
|
<div class="c-menu-button c-ctrl-wrapper c-ctrl-wrapper--menus-left">
|
||||||
<button
|
<button
|
||||||
|
aria-label="Time Conductor History"
|
||||||
class="c-button--menu c-history-button icon-history"
|
class="c-button--menu c-history-button icon-history"
|
||||||
@click.prevent.stop="showHistoryMenu"
|
@click.prevent.stop="showHistoryMenu"
|
||||||
>
|
>
|
||||||
|
@ -145,10 +145,10 @@ export default {
|
|||||||
const annotationsToDelete = this.annotations.filter((annotation) => {
|
const annotationsToDelete = this.annotations.filter((annotation) => {
|
||||||
return annotation.tags.includes(tagToRemove);
|
return annotation.tags.includes(tagToRemove);
|
||||||
});
|
});
|
||||||
const result = await this.openmct.annotation.deleteAnnotations(annotationsToDelete);
|
if (annotationsToDelete) {
|
||||||
this.$emit('tags-updated', annotationsToDelete);
|
await this.openmct.annotation.deleteAnnotations(annotationsToDelete);
|
||||||
|
this.$emit('tags-updated', annotationsToDelete);
|
||||||
return result;
|
}
|
||||||
},
|
},
|
||||||
async tagAdded(newTag) {
|
async tagAdded(newTag) {
|
||||||
// Either undelete an annotation, or create one (1) new annotation
|
// Either undelete an annotation, or create one (1) new annotation
|
||||||
|
@ -33,7 +33,8 @@
|
|||||||
class="c-tree__item c-elements-pool__item"
|
class="c-tree__item c-elements-pool__item"
|
||||||
:class="{
|
:class="{
|
||||||
'is-context-clicked': contextClickActive,
|
'is-context-clicked': contextClickActive,
|
||||||
'hover': hover
|
'hover': hover,
|
||||||
|
'is-alias': isAlias
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@ -55,6 +56,7 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
ObjectLabel
|
ObjectLabel
|
||||||
},
|
},
|
||||||
|
inject: ['openmct'],
|
||||||
props: {
|
props: {
|
||||||
index: {
|
index: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@ -82,9 +84,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
const isAlias = this.elementObject.location !== this.openmct.objects.makeKeyString(this.parentObject.identifier);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contextClickActive: false,
|
contextClickActive: false,
|
||||||
hover: false
|
hover: false,
|
||||||
|
isAlias
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -8,6 +8,15 @@
|
|||||||
margin-top: $interiorMargin;
|
margin-top: $interiorMargin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
&.is-alias {
|
||||||
|
// Object is an alias to an original.
|
||||||
|
[class*='__type-icon'] {
|
||||||
|
@include isAlias();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__search {
|
&__search {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
@ -232,6 +232,8 @@ describe("GrandSearch", () => {
|
|||||||
it("should render an object search result if new object added", async () => {
|
it("should render an object search result if new object added", async () => {
|
||||||
const composition = openmct.composition.get(mockFolderObject);
|
const composition = openmct.composition.get(mockFolderObject);
|
||||||
composition.add(mockNewObject);
|
composition.add(mockNewObject);
|
||||||
|
// after adding, need to wait a beat for the folder to be indexed
|
||||||
|
await Vue.nextTick();
|
||||||
await grandSearchComponent.$children[0].searchEverything('apple');
|
await grandSearchComponent.$children[0].searchEverything('apple');
|
||||||
await Vue.nextTick();
|
await Vue.nextTick();
|
||||||
const searchResults = document.querySelectorAll('[aria-label="New Apple Test Folder folder result"]');
|
const searchResults = document.querySelectorAll('[aria-label="New Apple Test Folder folder result"]');
|
||||||
@ -271,6 +273,13 @@ describe("GrandSearch", () => {
|
|||||||
expect(annotationResults[1].innerText).toContain('Driving');
|
expect(annotationResults[1].innerText).toContain('Driving');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should render no annotation search results if no match", async () => {
|
||||||
|
await grandSearchComponent.$children[0].searchEverything('Qbert');
|
||||||
|
await Vue.nextTick();
|
||||||
|
const annotationResults = document.querySelectorAll('[aria-label="Search Result"]');
|
||||||
|
expect(annotationResults.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("should preview object search results in edit mode if object clicked", async () => {
|
it("should preview object search results in edit mode if object clicked", async () => {
|
||||||
await grandSearchComponent.$children[0].searchEverything('Folder');
|
await grandSearchComponent.$children[0].searchEverything('Folder');
|
||||||
grandSearchComponent._provided.openmct.router.path = [mockDisplayLayout];
|
grandSearchComponent._provided.openmct.router.path = [mockDisplayLayout];
|
||||||
|
Loading…
x
Reference in New Issue
Block a user