port SpotlightDialog unit test to rtl (#10194)
This commit is contained in:
parent
d66248c17c
commit
b870f6166c
1 changed files with 114 additions and 157 deletions
|
@ -14,25 +14,24 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// eslint-disable-next-line deprecate/import
|
import React from "react";
|
||||||
import { mount, ReactWrapper } from "enzyme";
|
|
||||||
import { mocked } from "jest-mock";
|
import { mocked } from "jest-mock";
|
||||||
import { IProtocol, IPublicRoomsChunkRoom, MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix";
|
import { IProtocol, IPublicRoomsChunkRoom, MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||||
import { sleep } from "matrix-js-sdk/src/utils";
|
|
||||||
import React from "react";
|
|
||||||
import { act } from "react-dom/test-utils";
|
|
||||||
import sanitizeHtml from "sanitize-html";
|
import sanitizeHtml from "sanitize-html";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
import SpotlightDialog, { Filter } from "../../../../src/components/views/dialogs/spotlight/SpotlightDialog";
|
import SpotlightDialog, { Filter } from "../../../../src/components/views/dialogs/spotlight/SpotlightDialog";
|
||||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||||
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../../src/models/LocalRoom";
|
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../../src/models/LocalRoom";
|
||||||
import { DirectoryMember, startDmOnFirstMessage } from "../../../../src/utils/direct-messages";
|
import { DirectoryMember, startDmOnFirstMessage } from "../../../../src/utils/direct-messages";
|
||||||
import DMRoomMap from "../../../../src/utils/DMRoomMap";
|
import DMRoomMap from "../../../../src/utils/DMRoomMap";
|
||||||
import { mkRoom, stubClient } from "../../../test-utils";
|
import { flushPromisesWithFakeTimers, mkRoom, stubClient } from "../../../test-utils";
|
||||||
import { shouldShowFeedback } from "../../../../src/utils/Feedback";
|
import { shouldShowFeedback } from "../../../../src/utils/Feedback";
|
||||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||||
import { SettingLevel } from "../../../../src/settings/SettingLevel";
|
import { SettingLevel } from "../../../../src/settings/SettingLevel";
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
jest.mock("../../../../src/utils/Feedback");
|
jest.mock("../../../../src/utils/Feedback");
|
||||||
|
|
||||||
jest.mock("../../../../src/utils/direct-messages", () => ({
|
jest.mock("../../../../src/utils/direct-messages", () => ({
|
||||||
|
@ -77,7 +76,9 @@ function mockClient({
|
||||||
!searchTerm ||
|
!searchTerm ||
|
||||||
it.room_id.toLowerCase().includes(searchTerm) ||
|
it.room_id.toLowerCase().includes(searchTerm) ||
|
||||||
it.name?.toLowerCase().includes(searchTerm) ||
|
it.name?.toLowerCase().includes(searchTerm) ||
|
||||||
sanitizeHtml(it?.topic, { allowedTags: [] }).toLowerCase().includes(searchTerm) ||
|
sanitizeHtml(it?.topic || "", { allowedTags: [] })
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchTerm) ||
|
||||||
it.canonical_alias?.toLowerCase().includes(searchTerm) ||
|
it.canonical_alias?.toLowerCase().includes(searchTerm) ||
|
||||||
it.aliases?.find((alias) => alias.toLowerCase().includes(searchTerm)),
|
it.aliases?.find((alias) => alias.toLowerCase().includes(searchTerm)),
|
||||||
);
|
);
|
||||||
|
@ -149,58 +150,49 @@ describe("Spotlight Dialog", () => {
|
||||||
|
|
||||||
describe("should apply filters supplied via props", () => {
|
describe("should apply filters supplied via props", () => {
|
||||||
it("without filter", async () => {
|
it("without filter", async () => {
|
||||||
const wrapper = mount(<SpotlightDialog onFinished={() => null} />);
|
render(<SpotlightDialog onFinished={() => null} />);
|
||||||
await act(async () => {
|
|
||||||
await sleep(200);
|
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
const filterChip = document.querySelector("div.mx_SpotlightDialog_filter");
|
||||||
expect(filterChip.exists()).toBeFalsy();
|
expect(filterChip).not.toBeInTheDocument();
|
||||||
|
|
||||||
wrapper.unmount();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("with public room filter", async () => {
|
it("with public room filter", async () => {
|
||||||
const wrapper = mount(<SpotlightDialog initialFilter={Filter.PublicRooms} onFinished={() => null} />);
|
render(<SpotlightDialog initialFilter={Filter.PublicRooms} onFinished={() => null} />);
|
||||||
await act(async () => {
|
|
||||||
await sleep(200);
|
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
// search is debounced
|
||||||
expect(filterChip.exists()).toBeTruthy();
|
jest.advanceTimersByTime(200);
|
||||||
expect(filterChip.text()).toEqual("Public rooms");
|
await flushPromisesWithFakeTimers();
|
||||||
|
|
||||||
const content = wrapper.find("#mx_SpotlightDialog_content");
|
const filterChip = document.querySelector("div.mx_SpotlightDialog_filter")!;
|
||||||
const options = content.find("div.mx_SpotlightDialog_option");
|
expect(filterChip).toBeInTheDocument();
|
||||||
|
expect(filterChip.innerHTML).toContain("Public rooms");
|
||||||
|
|
||||||
|
const content = document.querySelector("#mx_SpotlightDialog_content")!;
|
||||||
|
const options = content.querySelectorAll("div.mx_SpotlightDialog_option");
|
||||||
expect(options.length).toBe(1);
|
expect(options.length).toBe(1);
|
||||||
expect(options.first().text()).toContain(testPublicRoom.name);
|
expect(options[0].innerHTML).toContain(testPublicRoom.name);
|
||||||
|
|
||||||
wrapper.unmount();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("with people filter", async () => {
|
it("with people filter", async () => {
|
||||||
const wrapper = mount(
|
render(
|
||||||
<SpotlightDialog
|
<SpotlightDialog
|
||||||
initialFilter={Filter.People}
|
initialFilter={Filter.People}
|
||||||
initialText={testPerson.display_name}
|
initialText={testPerson.display_name}
|
||||||
onFinished={() => null}
|
onFinished={() => null}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await act(async () => {
|
// search is debounced
|
||||||
await sleep(200);
|
jest.advanceTimersByTime(200);
|
||||||
});
|
await flushPromisesWithFakeTimers();
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
const filterChip = document.querySelector("div.mx_SpotlightDialog_filter")!;
|
||||||
expect(filterChip.exists()).toBeTruthy();
|
expect(filterChip).toBeInTheDocument();
|
||||||
expect(filterChip.text()).toEqual("People");
|
expect(filterChip.innerHTML).toContain("People");
|
||||||
|
|
||||||
const content = wrapper.find("#mx_SpotlightDialog_content");
|
const content = document.querySelector("#mx_SpotlightDialog_content")!;
|
||||||
const options = content.find("div.mx_SpotlightDialog_option");
|
const options = content.querySelectorAll("div.mx_SpotlightDialog_option");
|
||||||
expect(options.length).toBeGreaterThanOrEqual(1);
|
expect(options.length).toBeGreaterThanOrEqual(1);
|
||||||
expect(options.first().text()).toContain(testPerson.display_name);
|
expect(options[0]!.innerHTML).toContain(testPerson.display_name);
|
||||||
|
|
||||||
wrapper.unmount();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -214,153 +206,128 @@ describe("Spotlight Dialog", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should call getVisibleRooms with MSC3946 dynamic room predecessors", async () => {
|
it("should call getVisibleRooms with MSC3946 dynamic room predecessors", async () => {
|
||||||
const wrapper = mount(<SpotlightDialog onFinished={() => null} />);
|
render(<SpotlightDialog onFinished={() => null} />);
|
||||||
await act(async () => {
|
jest.advanceTimersByTime(200);
|
||||||
await sleep(1);
|
await flushPromisesWithFakeTimers();
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
expect(mockedClient.getVisibleRooms).toHaveBeenCalledWith(true);
|
expect(mockedClient.getVisibleRooms).toHaveBeenCalledWith(true);
|
||||||
wrapper.unmount();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("should apply manually selected filter", () => {
|
describe("should apply manually selected filter", () => {
|
||||||
it("with public rooms", async () => {
|
it("with public rooms", async () => {
|
||||||
const wrapper = mount(<SpotlightDialog onFinished={() => null} />);
|
render(<SpotlightDialog onFinished={() => null} />);
|
||||||
await act(async () => {
|
jest.advanceTimersByTime(200);
|
||||||
await sleep(1);
|
await flushPromisesWithFakeTimers();
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
wrapper.find("#mx_SpotlightDialog_button_explorePublicRooms").first().simulate("click");
|
|
||||||
await act(async () => {
|
|
||||||
await sleep(200);
|
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
fireEvent.click(screen.getByText("Public rooms"));
|
||||||
expect(filterChip.exists()).toBeTruthy();
|
// wrapper.find("#mx_SpotlightDialog_button_explorePublicRooms").first().simulate("click");
|
||||||
expect(filterChip.text()).toEqual("Public rooms");
|
// search is debounced
|
||||||
|
jest.advanceTimersByTime(200);
|
||||||
|
await flushPromisesWithFakeTimers();
|
||||||
|
|
||||||
const content = wrapper.find("#mx_SpotlightDialog_content");
|
const filterChip = document.querySelector("div.mx_SpotlightDialog_filter")!;
|
||||||
const options = content.find("div.mx_SpotlightDialog_option");
|
expect(filterChip).toBeInTheDocument();
|
||||||
|
expect(filterChip.innerHTML).toContain("Public rooms");
|
||||||
|
|
||||||
|
const content = document.querySelector("#mx_SpotlightDialog_content")!;
|
||||||
|
const options = content.querySelectorAll("div.mx_SpotlightDialog_option");
|
||||||
expect(options.length).toBe(1);
|
expect(options.length).toBe(1);
|
||||||
expect(options.first().text()).toContain(testPublicRoom.name);
|
expect(options[0]!.innerHTML).toContain(testPublicRoom.name);
|
||||||
|
|
||||||
// assert that getVisibleRooms is called without MSC3946 dynamic room predecessors
|
// assert that getVisibleRooms is called without MSC3946 dynamic room predecessors
|
||||||
expect(mockedClient.getVisibleRooms).toHaveBeenCalledWith(false);
|
expect(mockedClient.getVisibleRooms).toHaveBeenCalledWith(false);
|
||||||
|
|
||||||
wrapper.unmount();
|
|
||||||
});
|
});
|
||||||
it("with people", async () => {
|
it("with people", async () => {
|
||||||
const wrapper = mount(<SpotlightDialog initialText={testPerson.display_name} onFinished={() => null} />);
|
render(<SpotlightDialog initialText={testPerson.display_name} onFinished={() => null} />);
|
||||||
await act(async () => {
|
jest.advanceTimersByTime(200);
|
||||||
await sleep(1);
|
await flushPromisesWithFakeTimers();
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
wrapper.find("#mx_SpotlightDialog_button_startChat").first().simulate("click");
|
|
||||||
await act(async () => {
|
|
||||||
await sleep(200);
|
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
fireEvent.click(screen.getByText("People"));
|
||||||
expect(filterChip.exists()).toBeTruthy();
|
|
||||||
expect(filterChip.text()).toEqual("People");
|
|
||||||
|
|
||||||
const content = wrapper.find("#mx_SpotlightDialog_content");
|
// search is debounced
|
||||||
const options = content.find("div.mx_SpotlightDialog_option");
|
jest.advanceTimersByTime(200);
|
||||||
|
await flushPromisesWithFakeTimers();
|
||||||
|
|
||||||
|
const filterChip = document.querySelector("div.mx_SpotlightDialog_filter")!;
|
||||||
|
expect(filterChip).toBeInTheDocument();
|
||||||
|
expect(filterChip.innerHTML).toContain("People");
|
||||||
|
|
||||||
|
const content = document.querySelector("#mx_SpotlightDialog_content")!;
|
||||||
|
const options = content.querySelectorAll("div.mx_SpotlightDialog_option");
|
||||||
expect(options.length).toBeGreaterThanOrEqual(1);
|
expect(options.length).toBeGreaterThanOrEqual(1);
|
||||||
expect(options.first().text()).toContain(testPerson.display_name);
|
expect(options[0]!.innerHTML).toContain(testPerson.display_name);
|
||||||
|
|
||||||
wrapper.unmount();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("should allow clearing filter manually", () => {
|
describe("should allow clearing filter manually", () => {
|
||||||
it("with public room filter", async () => {
|
it("with public room filter", async () => {
|
||||||
const wrapper = mount(<SpotlightDialog initialFilter={Filter.PublicRooms} onFinished={() => null} />);
|
render(<SpotlightDialog initialFilter={Filter.PublicRooms} onFinished={() => null} />);
|
||||||
await act(async () => {
|
// search is debounced
|
||||||
await sleep(200);
|
jest.advanceTimersByTime(200);
|
||||||
});
|
await flushPromisesWithFakeTimers();
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
let filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
let filterChip = document.querySelector("div.mx_SpotlightDialog_filter")!;
|
||||||
expect(filterChip.exists()).toBeTruthy();
|
expect(filterChip).toBeInTheDocument();
|
||||||
expect(filterChip.text()).toEqual("Public rooms");
|
expect(filterChip.innerHTML).toContain("Public rooms");
|
||||||
|
|
||||||
filterChip.find("div.mx_SpotlightDialog_filter--close").simulate("click");
|
fireEvent.click(filterChip.querySelector("div.mx_SpotlightDialog_filter--close")!);
|
||||||
await act(async () => {
|
jest.advanceTimersByTime(200);
|
||||||
await sleep(1);
|
await flushPromisesWithFakeTimers();
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
filterChip = document.querySelector("div.mx_SpotlightDialog_filter")!;
|
||||||
expect(filterChip.exists()).toBeFalsy();
|
expect(filterChip).not.toBeInTheDocument();
|
||||||
|
|
||||||
wrapper.unmount();
|
|
||||||
});
|
});
|
||||||
it("with people filter", async () => {
|
it("with people filter", async () => {
|
||||||
const wrapper = mount(
|
render(
|
||||||
<SpotlightDialog
|
<SpotlightDialog
|
||||||
initialFilter={Filter.People}
|
initialFilter={Filter.People}
|
||||||
initialText={testPerson.display_name}
|
initialText={testPerson.display_name}
|
||||||
onFinished={() => null}
|
onFinished={() => null}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await act(async () => {
|
// search is debounced
|
||||||
await sleep(200);
|
jest.advanceTimersByTime(200);
|
||||||
});
|
await flushPromisesWithFakeTimers();
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
let filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
let filterChip = document.querySelector("div.mx_SpotlightDialog_filter");
|
||||||
expect(filterChip.exists()).toBeTruthy();
|
expect(filterChip).toBeInTheDocument();
|
||||||
expect(filterChip.text()).toEqual("People");
|
expect(filterChip!.innerHTML).toContain("People");
|
||||||
|
|
||||||
filterChip.find("div.mx_SpotlightDialog_filter--close").simulate("click");
|
fireEvent.click(filterChip!.querySelector("div.mx_SpotlightDialog_filter--close")!);
|
||||||
await act(async () => {
|
jest.advanceTimersByTime(1);
|
||||||
await sleep(1);
|
await flushPromisesWithFakeTimers();
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
filterChip = document.querySelector("div.mx_SpotlightDialog_filter");
|
||||||
expect(filterChip.exists()).toBeFalsy();
|
expect(filterChip).not.toBeInTheDocument();
|
||||||
|
|
||||||
wrapper.unmount();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("searching for rooms", () => {
|
describe("searching for rooms", () => {
|
||||||
let wrapper: ReactWrapper;
|
let options: NodeListOf<Element>;
|
||||||
let options: ReactWrapper;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
wrapper = mount(<SpotlightDialog initialText="test23" onFinished={() => null} />);
|
render(<SpotlightDialog initialText="test23" onFinished={() => null} />);
|
||||||
await act(async () => {
|
// search is debounced
|
||||||
await sleep(200);
|
jest.advanceTimersByTime(200);
|
||||||
});
|
await flushPromisesWithFakeTimers();
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
const content = wrapper.find("#mx_SpotlightDialog_content");
|
const content = document.querySelector("#mx_SpotlightDialog_content")!;
|
||||||
options = content.find("div.mx_SpotlightDialog_option");
|
options = content.querySelectorAll("div.mx_SpotlightDialog_option");
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
wrapper.unmount();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should find Rooms", () => {
|
it("should find Rooms", () => {
|
||||||
expect(options.length).toBe(3);
|
expect(options.length).toBe(3);
|
||||||
expect(options.first().text()).toContain(testRoom.name);
|
expect(options[0]!.innerHTML).toContain(testRoom.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not find LocalRooms", () => {
|
it("should not find LocalRooms", () => {
|
||||||
expect(options.length).toBe(3);
|
expect(options.length).toBe(3);
|
||||||
expect(options.first().text()).not.toContain(testLocalRoom.name);
|
expect(options[0]!.innerHTML).not.toContain(testLocalRoom.name);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should start a DM when clicking a person", async () => {
|
it("should start a DM when clicking a person", async () => {
|
||||||
const wrapper = mount(
|
render(
|
||||||
<SpotlightDialog
|
<SpotlightDialog
|
||||||
initialFilter={Filter.People}
|
initialFilter={Filter.People}
|
||||||
initialText={testPerson.display_name}
|
initialText={testPerson.display_name}
|
||||||
|
@ -368,46 +335,36 @@ describe("Spotlight Dialog", () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await act(async () => {
|
jest.advanceTimersByTime(200);
|
||||||
await sleep(200);
|
await flushPromisesWithFakeTimers();
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
const options = wrapper.find("div.mx_SpotlightDialog_option");
|
const options = document.querySelectorAll("div.mx_SpotlightDialog_option");
|
||||||
expect(options.length).toBeGreaterThanOrEqual(1);
|
expect(options.length).toBeGreaterThanOrEqual(1);
|
||||||
expect(options.first().text()).toContain(testPerson.display_name);
|
expect(options[0]!.innerHTML).toContain(testPerson.display_name);
|
||||||
|
|
||||||
options.first().simulate("click");
|
fireEvent.click(options[0]!);
|
||||||
expect(startDmOnFirstMessage).toHaveBeenCalledWith(mockedClient, [new DirectoryMember(testPerson)]);
|
expect(startDmOnFirstMessage).toHaveBeenCalledWith(mockedClient, [new DirectoryMember(testPerson)]);
|
||||||
|
|
||||||
wrapper.unmount();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Feedback prompt", () => {
|
describe("Feedback prompt", () => {
|
||||||
it("should show feedback prompt if feedback is enabled", async () => {
|
it("should show feedback prompt if feedback is enabled", async () => {
|
||||||
mocked(shouldShowFeedback).mockReturnValue(true);
|
mocked(shouldShowFeedback).mockReturnValue(true);
|
||||||
|
|
||||||
const wrapper = mount(<SpotlightDialog initialText="test23" onFinished={() => null} />);
|
render(<SpotlightDialog initialText="test23" onFinished={() => null} />);
|
||||||
await act(async () => {
|
jest.advanceTimersByTime(200);
|
||||||
await sleep(200);
|
await flushPromisesWithFakeTimers();
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
const content = wrapper.find(".mx_SpotlightDialog_footer");
|
expect(screen.getByText("give feedback")).toBeInTheDocument();
|
||||||
expect(content.childAt(0).text()).toBe("Results not as expected? Please give feedback.");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should hide feedback prompt if feedback is disabled", async () => {
|
it("should hide feedback prompt if feedback is disabled", async () => {
|
||||||
mocked(shouldShowFeedback).mockReturnValue(false);
|
mocked(shouldShowFeedback).mockReturnValue(false);
|
||||||
|
|
||||||
const wrapper = mount(<SpotlightDialog initialText="test23" onFinished={() => null} />);
|
render(<SpotlightDialog initialText="test23" onFinished={() => null} />);
|
||||||
await act(async () => {
|
jest.advanceTimersByTime(200);
|
||||||
await sleep(200);
|
await flushPromisesWithFakeTimers();
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
const content = wrapper.find(".mx_SpotlightDialog_footer");
|
expect(screen.queryByText("give feedback")).not.toBeInTheDocument();
|
||||||
expect(content.text()).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue