mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-12-23 08:49:48 +01:00
Add keyboard shortcut for global search (#2118)
Enable users to jump to the global search bar by pressing the "/" key from anywhere. Open modals block this shortcut. This PR also introduces a generic system for declaring global shortcuts by utilizing the third-party library mousetrap.
This commit is contained in:
committed by
GitHub
parent
1e72eb52bd
commit
af9aaec095
2
gradle/changelog/global_search_shortcut.yaml
Normal file
2
gradle/changelog/global_search_shortcut.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- type: added
|
||||||
|
description: Keyboard shortcut for global search ([#2118](https://github.com/scm-manager/scm-manager/pull/2118))
|
||||||
@@ -30,4 +30,8 @@ Um mehr über die fortgeschrittene Suche zu erfahren, lesen sie unsere [Experten
|
|||||||
- Die relevantesten Repositories werden in den Quick Results angezeigt.
|
- Die relevantesten Repositories werden in den Quick Results angezeigt.
|
||||||
- Über die Eingabe-Taste oder den Button "Alle Ergebnisse anzeigen" bekommen Sie Ergebnisse aller durchsuchten Entitäten wie Nutzern oder Gruppen.
|
- Über die Eingabe-Taste oder den Button "Alle Ergebnisse anzeigen" bekommen Sie Ergebnisse aller durchsuchten Entitäten wie Nutzern oder Gruppen.
|
||||||
- Eine Wildcard für eine beliebige Anzahl an beliebigen Zeichen wird Ihrer Suche standardmäßig angehängt.
|
- Eine Wildcard für eine beliebige Anzahl an beliebigen Zeichen wird Ihrer Suche standardmäßig angehängt.
|
||||||
- Geben Sie keine Wildcards vor dem Suchbegriff ein!`;
|
- Geben Sie keine Wildcards vor dem Suchbegriff ein!
|
||||||
|
|
||||||
|
### Tastenkombinationen
|
||||||
|
|
||||||
|
Sie können von überall aus "/" drücken, um den Tastaturfokus auf die globale Suchleiste bewegen.`;
|
||||||
|
|||||||
@@ -30,4 +30,9 @@ To learn about advanced search read our [Expert Search Site](/help/search-syntax
|
|||||||
- The most relevant repositories are shown in the quick results.
|
- The most relevant repositories are shown in the quick results.
|
||||||
- Press "enter" or click the "Show all results" button to find more results for all entities like users or groups.
|
- Press "enter" or click the "Show all results" button to find more results for all entities like users or groups.
|
||||||
- A multi-character wildcard (*) is added to your search by default.
|
- A multi-character wildcard (*) is added to your search by default.
|
||||||
- Do not enter Wildcards in front of the search!`;
|
- Do not enter Wildcards in front of the search!
|
||||||
|
|
||||||
|
### Shortcuts
|
||||||
|
|
||||||
|
You can press "/" from anywhere to move keyboard focus to the global search bar.
|
||||||
|
`;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import ActiveModalCountContext from "./activeModalCountContext";
|
||||||
|
import React, { FC, useCallback, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple counter that allows developers to keep track of how many modals are currently open.
|
||||||
|
*
|
||||||
|
* A default provider instance wraps the SCM-Manager so there is no need for plugins to use this component.
|
||||||
|
*
|
||||||
|
* Required by {@link useActiveModals}.
|
||||||
|
*/
|
||||||
|
const ActiveModalCountContextProvider: FC = ({ children }) => {
|
||||||
|
const [activeModalCount, setActiveModalCount] = useState(0);
|
||||||
|
const incrementModalCount = useCallback(() => setActiveModalCount((prev) => prev + 1), []);
|
||||||
|
const decrementModalCount = useCallback(() => setActiveModalCount((prev) => prev - 1), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActiveModalCountContext.Provider
|
||||||
|
value={{ value: activeModalCount, increment: incrementModalCount, decrement: decrementModalCount }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ActiveModalCountContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActiveModalCountContextProvider;
|
||||||
@@ -26,6 +26,7 @@ import { storiesOf } from "@storybook/react";
|
|||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import ConfirmAlert, { confirmAlert } from "./ConfirmAlert";
|
import ConfirmAlert, { confirmAlert } from "./ConfirmAlert";
|
||||||
|
import ActiveModalCountContext from "./activeModalCountContext";
|
||||||
|
|
||||||
const body =
|
const body =
|
||||||
"Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows\n " +
|
"Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows\n " +
|
||||||
@@ -37,27 +38,36 @@ const buttons = [
|
|||||||
{
|
{
|
||||||
className: "is-outlined",
|
className: "is-outlined",
|
||||||
label: "Cancel",
|
label: "Cancel",
|
||||||
onClick: () => null
|
onClick: () => null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Submit"
|
label: "Submit",
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const buttonsWithAutofocus = [
|
const buttonsWithAutofocus = [
|
||||||
{
|
{
|
||||||
label: "Cancel",
|
label: "Cancel",
|
||||||
onClick: () => null
|
onClick: () => null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
className: "is-info",
|
className: "is-info",
|
||||||
label: "I should be focused",
|
label: "I should be focused",
|
||||||
autofocus: true
|
autofocus: true,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const doNothing = () => {
|
||||||
|
// Do nothing
|
||||||
|
};
|
||||||
|
|
||||||
storiesOf("Modal/ConfirmAlert", module)
|
storiesOf("Modal/ConfirmAlert", module)
|
||||||
.addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
|
.addDecorator((story) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
|
||||||
|
.addDecorator((story) => (
|
||||||
|
<ActiveModalCountContext.Provider value={{ value: 0, increment: doNothing, decrement: doNothing }}>
|
||||||
|
{story()}
|
||||||
|
</ActiveModalCountContext.Provider>
|
||||||
|
))
|
||||||
.add("Default", () => <ConfirmAlert message={body} title={"Are you sure about that?"} buttons={buttons} />)
|
.add("Default", () => <ConfirmAlert message={body} title={"Are you sure about that?"} buttons={buttons} />)
|
||||||
.add("WithButton", () => {
|
.add("WithButton", () => {
|
||||||
const buttonClick = () => {
|
const buttonClick = () => {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { Button, ButtonGroup } from "../buttons";
|
|||||||
import Notification from "../Notification";
|
import Notification from "../Notification";
|
||||||
import { Autocomplete } from "../index";
|
import { Autocomplete } from "../index";
|
||||||
import { SelectValue } from "@scm-manager/ui-types";
|
import { SelectValue } from "@scm-manager/ui-types";
|
||||||
|
import ActiveModalCountContext from "./activeModalCountContext";
|
||||||
|
|
||||||
const TopAndBottomMargin = styled.div`
|
const TopAndBottomMargin = styled.div`
|
||||||
margin: 0.75rem 0; // only for aesthetic reasons
|
margin: 0.75rem 0; // only for aesthetic reasons
|
||||||
@@ -75,20 +76,25 @@ const withFormElementsFooter = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const loadSuggestions: (p: string) => Promise<SelectValue[]> = () =>
|
const loadSuggestions: (p: string) => Promise<SelectValue[]> = () =>
|
||||||
new Promise(resolve => {
|
new Promise((resolve) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
resolve([
|
resolve([
|
||||||
{ value: { id: "trillian", displayName: "Tricia McMillan" }, label: "Tricia McMillan" },
|
{ value: { id: "trillian", displayName: "Tricia McMillan" }, label: "Tricia McMillan" },
|
||||||
{ value: { id: "zaphod", displayName: "Zaphod Beeblebrox" }, label: "Zaphod Beeblebrox" },
|
{ value: { id: "zaphod", displayName: "Zaphod Beeblebrox" }, label: "Zaphod Beeblebrox" },
|
||||||
{ value: { id: "ford", displayName: "Ford Prefect" }, label: "Ford Prefect" },
|
{ value: { id: "ford", displayName: "Ford Prefect" }, label: "Ford Prefect" },
|
||||||
{ value: { id: "dent", displayName: "Arthur Dent" }, label: "Arthur Dent" },
|
{ value: { id: "dent", displayName: "Arthur Dent" }, label: "Arthur Dent" },
|
||||||
{ value: { id: "marvin", displayName: "Marvin" }, label: "Marvin the Paranoid Android " }
|
{ value: { id: "marvin", displayName: "Marvin" }, label: "Marvin the Paranoid Android " },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
storiesOf("Modal/Modal", module)
|
storiesOf("Modal/Modal", module)
|
||||||
.addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
|
.addDecorator((story) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
|
||||||
|
.addDecorator((story) => (
|
||||||
|
<ActiveModalCountContext.Provider value={{ value: 0, increment: doNothing, decrement: doNothing }}>
|
||||||
|
{story()}
|
||||||
|
</ActiveModalCountContext.Provider>
|
||||||
|
))
|
||||||
.add("Default", () => (
|
.add("Default", () => (
|
||||||
<NonCloseableModal>
|
<NonCloseableModal>
|
||||||
<p>{text}</p>
|
<p>{text}</p>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import classNames from "classnames";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { Dialog } from "@headlessui/react";
|
import { Dialog } from "@headlessui/react";
|
||||||
import { devices } from "../devices";
|
import { devices } from "../devices";
|
||||||
|
import useRegisterModal from "./useRegisterModal";
|
||||||
|
|
||||||
type ModalSize = "S" | "M" | "L";
|
type ModalSize = "S" | "M" | "L";
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ export const Modal: FC<Props> = ({
|
|||||||
initialFocusRef,
|
initialFocusRef,
|
||||||
overflowVisible
|
overflowVisible
|
||||||
}) => {
|
}) => {
|
||||||
|
useRegisterModal(active);
|
||||||
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
let showFooter = null;
|
let showFooter = null;
|
||||||
|
|
||||||
|
|||||||
29
scm-ui/ui-components/src/modals/activeModalCountContext.ts
Normal file
29
scm-ui/ui-components/src/modals/activeModalCountContext.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export type ModalStateContextType = { value: number; increment: () => void; decrement: () => void };
|
||||||
|
|
||||||
|
export default React.createContext<ModalStateContextType>({} as ModalStateContextType);
|
||||||
@@ -27,3 +27,5 @@
|
|||||||
export { default as ConfirmAlert, confirmAlert } from "./ConfirmAlert";
|
export { default as ConfirmAlert, confirmAlert } from "./ConfirmAlert";
|
||||||
export { default as Modal } from "./Modal";
|
export { default as Modal } from "./Modal";
|
||||||
export { default as FullscreenModal } from "./FullscreenModal";
|
export { default as FullscreenModal } from "./FullscreenModal";
|
||||||
|
export { default as ActiveModalCountContextProvider } from "./ActiveModalCountContextProvider";
|
||||||
|
export { default as useActiveModals } from "./useActiveModals";
|
||||||
|
|||||||
36
scm-ui/ui-components/src/modals/useActiveModals.ts
Normal file
36
scm-ui/ui-components/src/modals/useActiveModals.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useContext } from "react";
|
||||||
|
import ActiveModalCountContext from "./activeModalCountContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns Whether any modals are currently open
|
||||||
|
*/
|
||||||
|
const useActiveModals = () => {
|
||||||
|
const { value } = useContext(ActiveModalCountContext);
|
||||||
|
return value > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useActiveModals;
|
||||||
88
scm-ui/ui-components/src/modals/useRegisterModal.test.tsx
Normal file
88
scm-ui/ui-components/src/modals/useRegisterModal.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderHook } from "@testing-library/react-hooks";
|
||||||
|
import React, { FC } from "react";
|
||||||
|
import ActiveModalCountContext, { ModalStateContextType } from "./activeModalCountContext";
|
||||||
|
import useRegisterModal from "./useRegisterModal";
|
||||||
|
|
||||||
|
const createWrapper =
|
||||||
|
(context: ModalStateContextType): FC =>
|
||||||
|
({ children }) => {
|
||||||
|
return <ActiveModalCountContext.Provider value={context}>{children}</ActiveModalCountContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useRegisterModal", () => {
|
||||||
|
it("should increment and not decrement on active registration", () => {
|
||||||
|
const increment = jest.fn();
|
||||||
|
const decrement = jest.fn();
|
||||||
|
|
||||||
|
renderHook(() => useRegisterModal(true), {
|
||||||
|
wrapper: createWrapper({ value: 0, increment, decrement }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decrement).not.toHaveBeenCalled();
|
||||||
|
expect(increment).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not decrement on inactive registration", () => {
|
||||||
|
const increment = jest.fn();
|
||||||
|
const decrement = jest.fn();
|
||||||
|
|
||||||
|
renderHook(() => useRegisterModal(false), {
|
||||||
|
wrapper: createWrapper({ value: 0, increment, decrement }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decrement).not.toHaveBeenCalled();
|
||||||
|
expect(increment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not decrement on inactive de-registration", () => {
|
||||||
|
const increment = jest.fn();
|
||||||
|
const decrement = jest.fn();
|
||||||
|
|
||||||
|
const result = renderHook(() => useRegisterModal(false), {
|
||||||
|
wrapper: createWrapper({ value: 0, increment, decrement }),
|
||||||
|
});
|
||||||
|
|
||||||
|
result.unmount();
|
||||||
|
|
||||||
|
expect(decrement).not.toHaveBeenCalled();
|
||||||
|
expect(increment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should decrement on active de-registration", () => {
|
||||||
|
const increment = jest.fn();
|
||||||
|
const decrement = jest.fn();
|
||||||
|
|
||||||
|
const result = renderHook(() => useRegisterModal(false, true), {
|
||||||
|
wrapper: createWrapper({ value: 0, increment, decrement }),
|
||||||
|
});
|
||||||
|
|
||||||
|
result.unmount();
|
||||||
|
|
||||||
|
expect(decrement).toHaveBeenCalled();
|
||||||
|
expect(increment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
53
scm-ui/ui-components/src/modals/useRegisterModal.ts
Normal file
53
scm-ui/ui-components/src/modals/useRegisterModal.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useContext, useEffect, useRef } from "react";
|
||||||
|
import ActiveModalCount from "./activeModalCountContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should not yet be part of the public API, as it is exclusively used by the {@link Modal} component.
|
||||||
|
*
|
||||||
|
* @param active Whether the modal is currently open
|
||||||
|
* @param initialValue DO NOT USE - Used only for testing purposes
|
||||||
|
*/
|
||||||
|
export default function useRegisterModal(active: boolean, initialValue: boolean | null = null) {
|
||||||
|
const { increment, decrement } = useContext(ActiveModalCount);
|
||||||
|
const previousActiveState = useRef<boolean | null>(initialValue);
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) {
|
||||||
|
previousActiveState.current = true;
|
||||||
|
increment();
|
||||||
|
} else {
|
||||||
|
if (previousActiveState.current !== null) {
|
||||||
|
decrement();
|
||||||
|
}
|
||||||
|
previousActiveState.current = false;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (previousActiveState.current) {
|
||||||
|
decrement();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [active, decrement, increment]);
|
||||||
|
}
|
||||||
@@ -26,7 +26,8 @@
|
|||||||
"react-select": "^2.1.2",
|
"react-select": "^2.1.2",
|
||||||
"string_score": "^0.1.22",
|
"string_score": "^0.1.22",
|
||||||
"styled-components": "^5.3.5",
|
"styled-components": "^5.3.5",
|
||||||
"systemjs": "0.21.6"
|
"systemjs": "0.21.6",
|
||||||
|
"mousetrap": "^1.6.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@types/styled-components": "^5.1.25",
|
"@types/styled-components": "^5.1.25",
|
||||||
"@types/systemjs": "^0.20.6",
|
"@types/systemjs": "^0.20.6",
|
||||||
|
"@types/mousetrap": "^1.6.9",
|
||||||
"fetch-mock": "^7.5.1",
|
"fetch-mock": "^7.5.1",
|
||||||
"react-test-renderer": "^17.0.1"
|
"react-test-renderer": "^17.0.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { useIndex, useSubject } from "@scm-manager/ui-api";
|
|||||||
import NavigationBar from "./NavigationBar";
|
import NavigationBar from "./NavigationBar";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Feedback from "./Feedback";
|
import Feedback from "./Feedback";
|
||||||
|
import usePauseShortcutsWhenModalsActive from "../shortcuts/usePauseShortcutsWhenModalsActive";
|
||||||
|
|
||||||
const AppWrapper = styled.div`
|
const AppWrapper = styled.div`
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -41,6 +42,7 @@ const App: FC = () => {
|
|||||||
const { data: index } = useIndex();
|
const { data: index } = useIndex();
|
||||||
const { isLoading, error, isAuthenticated, isAnonymous, me } = useSubject();
|
const { isLoading, error, isAuthenticated, isAnonymous, me } = useSubject();
|
||||||
const [t] = useTranslation("commons");
|
const [t] = useTranslation("commons");
|
||||||
|
usePauseShortcutsWhenModalsActive();
|
||||||
|
|
||||||
if (!index) {
|
if (!index) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -23,11 +23,11 @@
|
|||||||
*/
|
*/
|
||||||
import React, { FC, useState } from "react";
|
import React, { FC, useState } from "react";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { ErrorBoundary, Header, Loading } from "@scm-manager/ui-components";
|
import { ActiveModalCountContextProvider, ErrorBoundary, Header, Loading } from "@scm-manager/ui-components";
|
||||||
import PluginLoader from "./PluginLoader";
|
import PluginLoader from "./PluginLoader";
|
||||||
import ScrollToTop from "./ScrollToTop";
|
import ScrollToTop from "./ScrollToTop";
|
||||||
import IndexErrorPage from "./IndexErrorPage";
|
import IndexErrorPage from "./IndexErrorPage";
|
||||||
import { useIndex, NamespaceAndNameContextProvider } from "@scm-manager/ui-api";
|
import { NamespaceAndNameContextProvider, useIndex } from "@scm-manager/ui-api";
|
||||||
import { Link } from "@scm-manager/ui-types";
|
import { Link } from "@scm-manager/ui-types";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
import { binder, extensionPoints } from "@scm-manager/ui-extensions";
|
import { binder, extensionPoints } from "@scm-manager/ui-extensions";
|
||||||
@@ -60,11 +60,13 @@ const Index: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<ErrorBoundary fallback={IndexErrorPage}>
|
<ErrorBoundary fallback={IndexErrorPage}>
|
||||||
<ScrollToTop>
|
<ScrollToTop>
|
||||||
|
<ActiveModalCountContextProvider>
|
||||||
<NamespaceAndNameContextProvider>
|
<NamespaceAndNameContextProvider>
|
||||||
<PluginLoader link={link} loaded={pluginsLoaded} callback={() => setPluginsLoaded(true)}>
|
<PluginLoader link={link} loaded={pluginsLoaded} callback={() => setPluginsLoaded(true)}>
|
||||||
<App />
|
<App />
|
||||||
</PluginLoader>
|
</PluginLoader>
|
||||||
</NamespaceAndNameContextProvider>
|
</NamespaceAndNameContextProvider>
|
||||||
|
</ActiveModalCountContextProvider>
|
||||||
</ScrollToTop>
|
</ScrollToTop>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import React, {
|
|||||||
SetStateAction,
|
SetStateAction,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo, useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Hit, Links, Repository, ValueHitField } from "@scm-manager/ui-types";
|
import { Hit, Links, Repository, ValueHitField } from "@scm-manager/ui-types";
|
||||||
@@ -45,6 +45,7 @@ import SyntaxModal from "../search/SyntaxModal";
|
|||||||
import SearchErrorNotification from "../search/SearchErrorNotification";
|
import SearchErrorNotification from "../search/SearchErrorNotification";
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
import { orderTypes } from "../search/Search";
|
import { orderTypes } from "../search/Search";
|
||||||
|
import useShortcut from "../shortcuts/useShortcut";
|
||||||
|
|
||||||
const Input = styled.input`
|
const Input = styled.input`
|
||||||
border-radius: 4px !important;
|
border-radius: 4px !important;
|
||||||
@@ -329,6 +330,7 @@ const OmniSearch: FC = () => {
|
|||||||
const [t] = useTranslation("commons");
|
const [t] = useTranslation("commons");
|
||||||
const { searchType, initialQuery } = useSearchParams();
|
const { searchType, initialQuery } = useSearchParams();
|
||||||
const [query, setQuery] = useState(initialQuery);
|
const [query, setQuery] = useState(initialQuery);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const debouncedQuery = useDebounce(query, 250);
|
const debouncedQuery = useDebounce(query, 250);
|
||||||
const context = useNamespaceAndNameContext();
|
const context = useNamespaceAndNameContext();
|
||||||
const { data, isLoading, error } = useOmniSearch(debouncedQuery, {
|
const { data, isLoading, error } = useOmniSearch(debouncedQuery, {
|
||||||
@@ -355,6 +357,7 @@ const OmniSearch: FC = () => {
|
|||||||
searchTypes.sort(orderTypes(t));
|
searchTypes.sort(orderTypes(t));
|
||||||
|
|
||||||
const id = useCallback(namespaceAndName, []);
|
const id = useCallback(namespaceAndName, []);
|
||||||
|
useShortcut("/", () => searchInputRef.current?.focus());
|
||||||
|
|
||||||
const entries = useMemo(() => {
|
const entries = useMemo(() => {
|
||||||
const newEntries = [];
|
const newEntries = [];
|
||||||
@@ -435,6 +438,7 @@ const OmniSearch: FC = () => {
|
|||||||
aria-label={t("search.ariaLabel")}
|
aria-label={t("search.ariaLabel")}
|
||||||
aria-owns="omni-search-results"
|
aria-owns="omni-search-results"
|
||||||
aria-activedescendant={index >= 0 ? "omni-search-selected-option" : undefined}
|
aria-activedescendant={index >= 0 ? "omni-search-selected-option" : undefined}
|
||||||
|
ref={searchInputRef}
|
||||||
{...handlers}
|
{...handlers}
|
||||||
/>
|
/>
|
||||||
{isLoading ? null : (
|
{isLoading ? null : (
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ import ChangesetShortLink from "./repos/components/changesets/ChangesetShortLink
|
|||||||
|
|
||||||
import "./tokenExpired";
|
import "./tokenExpired";
|
||||||
import { ApiProvider } from "@scm-manager/ui-api";
|
import { ApiProvider } from "@scm-manager/ui-api";
|
||||||
|
// Used by useShortcut
|
||||||
|
import "mousetrap";
|
||||||
|
// Used by usePauseShortcuts
|
||||||
|
import "mousetrap/plugins/pause/mousetrap-pause.min";
|
||||||
|
|
||||||
binder.bind<extensionPoints.ChangesetDescriptionTokens>("changeset.description.tokens", ChangesetShortLink);
|
binder.bind<extensionPoints.ChangesetDescriptionTokens>("changeset.description.tokens", ChangesetShortLink);
|
||||||
|
|
||||||
|
|||||||
43
scm-ui/ui-webapp/src/shortcuts/usePauseShortcuts.ts
Normal file
43
scm-ui/ui-webapp/src/shortcuts/usePauseShortcuts.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Mousetrap from "mousetrap";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pauses or unpauses all shortcuts provided by {@link useShortcut}.
|
||||||
|
*
|
||||||
|
* @param pause Whether shortcuts should be paused
|
||||||
|
*/
|
||||||
|
export default function usePauseShortcuts(pause: boolean) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (pause) {
|
||||||
|
// @ts-ignore method comes from plugin
|
||||||
|
Mousetrap.pause();
|
||||||
|
} else {
|
||||||
|
// @ts-ignore method comes from plugin
|
||||||
|
Mousetrap.unpause();
|
||||||
|
}
|
||||||
|
}, [pause]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useActiveModals } from "@scm-manager/ui-components";
|
||||||
|
import usePauseShortcuts from "./usePauseShortcuts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard shortcuts are not active in modals using {@link useActiveModals} to determine whether any modals are open.
|
||||||
|
*
|
||||||
|
* Has to be used inside a {@link ActiveModalCountContextProvider}.
|
||||||
|
*/
|
||||||
|
export default function usePauseShortcutsWhenModalsActive() {
|
||||||
|
const areModalsActive = useActiveModals();
|
||||||
|
usePauseShortcuts(areModalsActive);
|
||||||
|
}
|
||||||
73
scm-ui/ui-webapp/src/shortcuts/useShortcut.ts
Normal file
73
scm-ui/ui-webapp/src/shortcuts/useShortcut.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Mousetrap from "mousetrap";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ## Summary
|
||||||
|
*
|
||||||
|
* Binds a global keyboard shortcut to a given callback.
|
||||||
|
*
|
||||||
|
* The callback is automatically cleaned up upon unmount.
|
||||||
|
*
|
||||||
|
* ## Supported keys
|
||||||
|
*
|
||||||
|
* For modifier keys you can use shift, ctrl, alt, or meta.
|
||||||
|
*
|
||||||
|
* You can substitute option for alt and command for meta.
|
||||||
|
*
|
||||||
|
* Other special keys are backspace, tab, enter, return, capslock, esc, escape, space, pageup, pagedown, end, home, left, up, right, down, ins, del, and plus.
|
||||||
|
*
|
||||||
|
* Any other key you should be able to reference by name like a, /, $, *, or =.
|
||||||
|
*
|
||||||
|
* A "mod" helper exists which maps to either "command" on Mac and "ctrl" on Windows and Linux.
|
||||||
|
*
|
||||||
|
* ## Combinations
|
||||||
|
*
|
||||||
|
* Keys can be combined with the "+" separator, but without extra whitespaces.
|
||||||
|
*
|
||||||
|
* @param key
|
||||||
|
* @param callback
|
||||||
|
* @example useShortcut("/", ...)
|
||||||
|
* @example useShortcut("ctrl+shift+k", ...)
|
||||||
|
* @see https://github.com/ccampbell/mousetrap
|
||||||
|
*/
|
||||||
|
export default function useShortcut(key: string, callback: (e: KeyboardEvent) => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
Mousetrap.bind(key, (e) => {
|
||||||
|
callback(e);
|
||||||
|
/*
|
||||||
|
* Returning false disables default event behaviour and stops event bubbling.
|
||||||
|
* Otherwise, a shortcut that moves focus to an input field would cause the key to be entered into the input at the same time.
|
||||||
|
* We could move the decision to the callback, but this behaviour is an implementation detail of Mousetrap which we would like to hide.
|
||||||
|
*/
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Mousetrap.unbind(key);
|
||||||
|
};
|
||||||
|
}, [key, callback]);
|
||||||
|
}
|
||||||
10
yarn.lock
10
yarn.lock
@@ -3869,6 +3869,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c"
|
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c"
|
||||||
integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
|
integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
|
||||||
|
|
||||||
|
"@types/mousetrap@^1.6.9":
|
||||||
|
version "1.6.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.9.tgz#f1ef9adbd1eac3466f21b6988b1c82c633a45340"
|
||||||
|
integrity sha512-HUAiN65VsRXyFCTicolwb5+I7FM6f72zjMWr+ajGk+YTvzBgXqa2A5U7d+rtsouAkunJ5U4Sb5lNJjo9w+nmXg==
|
||||||
|
|
||||||
"@types/node-fetch@^2.5.7":
|
"@types/node-fetch@^2.5.7":
|
||||||
version "2.6.2"
|
version "2.6.2"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da"
|
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da"
|
||||||
@@ -13490,6 +13495,11 @@ moo@^0.5.0:
|
|||||||
resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4"
|
resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4"
|
||||||
integrity sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w==
|
integrity sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w==
|
||||||
|
|
||||||
|
mousetrap@^1.6.5:
|
||||||
|
version "1.6.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.5.tgz#8a766d8c272b08393d5f56074e0b5ec183485bf9"
|
||||||
|
integrity sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==
|
||||||
|
|
||||||
move-concurrently@^1.0.1:
|
move-concurrently@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
|
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
|
||||||
|
|||||||
Reference in New Issue
Block a user