mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-10 07:25:44 +01:00
Merged in feature/assign_ui_session_id (pull request #377)
API-Client Support for SSE and Toast Components
This commit is contained in:
@@ -336,7 +336,7 @@ exports[`Storyshots DateFromNow Default 1`] = `
|
||||
|
||||
exports[`Storyshots Forms|Checkbox Default 1`] = `
|
||||
<div
|
||||
className="sc-fBuWsC ldmpJA"
|
||||
className="sc-caSCKo brLbbv"
|
||||
>
|
||||
<div
|
||||
className="field"
|
||||
@@ -381,7 +381,7 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
|
||||
|
||||
exports[`Storyshots Forms|Checkbox Disabled 1`] = `
|
||||
<div
|
||||
className="sc-fBuWsC ldmpJA"
|
||||
className="sc-caSCKo brLbbv"
|
||||
>
|
||||
<div
|
||||
className="field"
|
||||
@@ -409,7 +409,7 @@ exports[`Storyshots Forms|Checkbox Disabled 1`] = `
|
||||
|
||||
exports[`Storyshots Forms|Radio Default 1`] = `
|
||||
<div
|
||||
className="sc-fMiknA keSQNk"
|
||||
className="sc-gisBJw jHakbY"
|
||||
>
|
||||
<label
|
||||
className="radio"
|
||||
@@ -438,7 +438,7 @@ exports[`Storyshots Forms|Radio Default 1`] = `
|
||||
|
||||
exports[`Storyshots Forms|Radio Disabled 1`] = `
|
||||
<div
|
||||
className="sc-fMiknA keSQNk"
|
||||
className="sc-gisBJw jHakbY"
|
||||
>
|
||||
<label
|
||||
className="radio"
|
||||
@@ -2558,3 +2558,31 @@ exports[`Storyshots Table|Table TextColumn 1`] = `
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
exports[`Storyshots Toast Danger 1`] = `null`;
|
||||
|
||||
exports[`Storyshots Toast Info 1`] = `null`;
|
||||
|
||||
exports[`Storyshots Toast Open/Close 1`] = `
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"padding": "2rem",
|
||||
}
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="button is-primary"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Open
|
||||
Toast
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Storyshots Toast Primary 1`] = `null`;
|
||||
|
||||
exports[`Storyshots Toast Success 1`] = `null`;
|
||||
|
||||
exports[`Storyshots Toast Warning 1`] = `null`;
|
||||
|
||||
@@ -1,6 +1,43 @@
|
||||
import { contextPath } from "./urls";
|
||||
import { createBackendError, ForbiddenError, isBackendError, UnauthorizedError } from "./errors";
|
||||
import { BackendErrorContent } from "./errors";
|
||||
// @ts-ignore we have not types for event-source-polyfill
|
||||
import { EventSourcePolyfill } from "event-source-polyfill";
|
||||
import { createBackendError, ForbiddenError, isBackendError, UnauthorizedError, BackendErrorContent } from "./errors";
|
||||
|
||||
type SubscriptionEvent = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
type OpenEvent = SubscriptionEvent;
|
||||
|
||||
type ErrorEvent = SubscriptionEvent & {
|
||||
error: Error;
|
||||
};
|
||||
|
||||
type MessageEvent = SubscriptionEvent & {
|
||||
data: string;
|
||||
lastEventId?: string;
|
||||
};
|
||||
|
||||
type MessageListeners = {
|
||||
[eventType: string]: (event: MessageEvent) => void;
|
||||
};
|
||||
|
||||
type SubscriptionContext = {
|
||||
onOpen?: OpenEvent;
|
||||
onMessage: MessageListeners;
|
||||
onError?: ErrorEvent;
|
||||
};
|
||||
|
||||
type SubscriptionArgument = MessageListeners | SubscriptionContext;
|
||||
|
||||
type Cancel = () => void;
|
||||
|
||||
const sessionId = (
|
||||
Date.now().toString(36) +
|
||||
Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 5)
|
||||
).toUpperCase();
|
||||
|
||||
const extractXsrfTokenFromJwt = (jwt: string) => {
|
||||
const parts = jwt.split(".");
|
||||
@@ -26,26 +63,34 @@ const extractXsrfToken = () => {
|
||||
return extractXsrfTokenFromCookie(document.cookie);
|
||||
};
|
||||
|
||||
const applyFetchOptions: (p: RequestInit) => RequestInit = o => {
|
||||
if (!o.headers) {
|
||||
o.headers = {};
|
||||
}
|
||||
|
||||
// @ts-ignore We are sure that here we only get headers of type Record<string, string>
|
||||
const headers: Record<string, string> = o.headers;
|
||||
headers["Cache"] = "no-cache";
|
||||
// identify the request as ajax request
|
||||
headers["X-Requested-With"] = "XMLHttpRequest";
|
||||
// identify the web interface
|
||||
headers["X-SCM-Client"] = "WUI";
|
||||
const createRequestHeaders = () => {
|
||||
const headers: { [key: string]: string } = {
|
||||
// disable caching for now
|
||||
Cache: "no-cache",
|
||||
// identify the request as ajax request
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
// identify the web interface
|
||||
"X-SCM-Client": "WUI",
|
||||
// identify the window session
|
||||
"X-SCM-Session-ID": sessionId
|
||||
};
|
||||
|
||||
const xsrf = extractXsrfToken();
|
||||
if (xsrf) {
|
||||
headers["X-XSRF-Token"] = xsrf;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const applyFetchOptions: (p: RequestInit) => RequestInit = o => {
|
||||
if (o.headers) {
|
||||
o.headers = {
|
||||
...createRequestHeaders()
|
||||
};
|
||||
} else {
|
||||
o.headers = createRequestHeaders();
|
||||
}
|
||||
o.credentials = "same-origin";
|
||||
o.headers = headers;
|
||||
return o;
|
||||
};
|
||||
|
||||
@@ -165,12 +210,39 @@ class ApiClient {
|
||||
if (!options.headers) {
|
||||
options.headers = {};
|
||||
}
|
||||
// @ts-ignore We are sure that here we only get headers of type Record<string, string>
|
||||
// @ts-ignore We are sure that here we only get headers of type {[name:string]: string}
|
||||
options.headers["Content-Type"] = contentType;
|
||||
}
|
||||
|
||||
return fetch(createUrl(url), options).then(handleFailure);
|
||||
}
|
||||
|
||||
subscribe(url: string, argument: SubscriptionArgument): Cancel {
|
||||
const es = new EventSourcePolyfill(createUrl(url), {
|
||||
withCredentials: true,
|
||||
headers: createRequestHeaders()
|
||||
});
|
||||
|
||||
let listeners: MessageListeners;
|
||||
// type guard, to identify that argument is of type SubscriptionContext
|
||||
if ("onMessage" in argument) {
|
||||
listeners = (argument as SubscriptionContext).onMessage;
|
||||
if (argument.onError) {
|
||||
es.onerror = argument.onError;
|
||||
}
|
||||
if (argument.onOpen) {
|
||||
es.onopen = argument.onOpen;
|
||||
}
|
||||
} else {
|
||||
listeners = argument;
|
||||
}
|
||||
|
||||
for (const type in listeners) {
|
||||
es.addEventListener(type, listeners[type]);
|
||||
}
|
||||
|
||||
return es.close;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
@@ -66,6 +66,7 @@ export * from "./modals";
|
||||
export * from "./navigation";
|
||||
export * from "./repos";
|
||||
export * from "./table";
|
||||
export * from "./toast";
|
||||
|
||||
export {
|
||||
File,
|
||||
|
||||
@@ -37,9 +37,9 @@ const Table: FC<Props> = ({ data, sortable, children, emptyMessage }) => {
|
||||
}
|
||||
});
|
||||
|
||||
const mapDataToColumns = (row: any) => {
|
||||
const mapDataToColumns = (row: any, rowIndex: number) => {
|
||||
return (
|
||||
<tr>
|
||||
<tr key={rowIndex}>
|
||||
{React.Children.map(children, (child, columnIndex) => {
|
||||
return <td>{React.cloneElement(child, { ...child.props, columnIndex, row })}</td>;
|
||||
})}
|
||||
@@ -93,6 +93,7 @@ const Table: FC<Props> = ({ data, sortable, children, emptyMessage }) => {
|
||||
onClick={isSortable(child) ? () => tableSort(index) : undefined}
|
||||
onMouseEnter={() => setHoveredColumnIndex(index)}
|
||||
onMouseLeave={() => setHoveredColumnIndex(undefined)}
|
||||
key={index}
|
||||
>
|
||||
{child.props.header}
|
||||
{isSortable(child) && renderSortIcon(child, ascending, shouldShowIcon(index))}
|
||||
|
||||
62
scm-ui/ui-components/src/toast/Toast.tsx
Normal file
62
scm-ui/ui-components/src/toast/Toast.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { FC } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import styled from "styled-components";
|
||||
import { getTheme, Themeable, ToastThemeContext, Type } from "./themes";
|
||||
import usePortalRootElement from "../usePortalRootElement";
|
||||
|
||||
type Props = {
|
||||
type: Type;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const Container = styled.div<Themeable>`
|
||||
z-index: 99999;
|
||||
position: fixed;
|
||||
padding: 1.5rem;
|
||||
right: 1.5rem;
|
||||
bottom: 1.5rem;
|
||||
color: ${props => props.theme.primary};
|
||||
background-color: ${props => props.theme.secondary};
|
||||
max-width: 18rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 5px;
|
||||
animation: 0.5s slide-up;
|
||||
|
||||
& > p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
bottom: -10rem;
|
||||
}
|
||||
to {
|
||||
bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Title = styled.h1<Themeable>`
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: bold;
|
||||
`;
|
||||
|
||||
const Toast: FC<Props> = ({ children, title, type }) => {
|
||||
const rootElement = usePortalRootElement("toastRoot");
|
||||
if (!rootElement) {
|
||||
// portal not yet ready
|
||||
return null;
|
||||
}
|
||||
|
||||
const theme = getTheme(type);
|
||||
const content = (
|
||||
<Container theme={theme}>
|
||||
<Title theme={theme}>{title}</Title>
|
||||
<ToastThemeContext.Provider value={theme}>{children}</ToastThemeContext.Provider>
|
||||
</Container>
|
||||
);
|
||||
|
||||
return createPortal(content, rootElement);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
37
scm-ui/ui-components/src/toast/ToastButton.tsx
Normal file
37
scm-ui/ui-components/src/toast/ToastButton.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { FC, useContext } from "react";
|
||||
import { ToastThemeContext, Themeable } from "./themes";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
const ThemedButton = styled.div.attrs(props => ({
|
||||
className: "button"
|
||||
}))<Themeable>`
|
||||
color: ${props => props.theme.primary};
|
||||
border-color: ${props => props.theme.primary};
|
||||
background-color: ${props => props.theme.secondary};
|
||||
font-size: 0.75rem;
|
||||
|
||||
&:hover {
|
||||
color: ${props => props.theme.primary};
|
||||
border-color: ${props => props.theme.tertiary};
|
||||
background-color: ${props => props.theme.tertiary};
|
||||
}
|
||||
`;
|
||||
|
||||
const ToastButtonIcon = styled.i`
|
||||
margin-right: 0.25rem;
|
||||
`;
|
||||
|
||||
const ToastButton: FC<Props> = ({ icon, children }) => {
|
||||
const theme = useContext(ToastThemeContext);
|
||||
return (
|
||||
<ThemedButton theme={theme}>
|
||||
{icon && <ToastButtonIcon className={`fas fa-fw fa-${icon}`} />} {children}
|
||||
</ThemedButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastButton;
|
||||
20
scm-ui/ui-components/src/toast/ToastButtons.tsx
Normal file
20
scm-ui/ui-components/src/toast/ToastButtons.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React, { FC } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Buttons = styled.div`
|
||||
display: flex;
|
||||
padding-top: 0.5rem;
|
||||
width: 100%;
|
||||
|
||||
& > * {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
& > *:not(:last-child) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const ToastButtons: FC = ({ children }) => <Buttons>{children}</Buttons>;
|
||||
|
||||
export default ToastButtons;
|
||||
42
scm-ui/ui-components/src/toast/index.stories.tsx
Normal file
42
scm-ui/ui-components/src/toast/index.stories.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useState } from "react";
|
||||
import { storiesOf } from "@storybook/react";
|
||||
import Toast from "./Toast";
|
||||
import ToastButtons from "./ToastButtons";
|
||||
import ToastButton from "./ToastButton";
|
||||
import { types } from "./themes";
|
||||
|
||||
const toastStories = storiesOf("Toast", module);
|
||||
|
||||
const AnimatedToast = () => (
|
||||
<Toast type="primary" title="Animated">
|
||||
Awesome animated Toast
|
||||
</Toast>
|
||||
);
|
||||
|
||||
const Animator = () => {
|
||||
const [display, setDisplay] = useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ padding: "2rem" }}>
|
||||
{display && <AnimatedToast />}
|
||||
<button className="button is-primary" onClick={() => setDisplay(!display)}>
|
||||
{display ? "Close" : "Open"} Toast
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
toastStories.add("Open/Close", () => <Animator />);
|
||||
|
||||
types.forEach(type => {
|
||||
toastStories.add(type.charAt(0).toUpperCase() + type.slice(1), () => (
|
||||
<Toast type={type} title="New Changes">
|
||||
<p>The underlying Pull-Request has changed. Press reload to see the changes.</p>
|
||||
<p>Warning: Non saved modification will be lost.</p>
|
||||
<ToastButtons>
|
||||
<ToastButton icon="redo">Reload</ToastButton>
|
||||
<ToastButton icon="times">Ignore</ToastButton>
|
||||
</ToastButtons>
|
||||
</Toast>
|
||||
));
|
||||
});
|
||||
3
scm-ui/ui-components/src/toast/index.ts
Normal file
3
scm-ui/ui-components/src/toast/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Toast } from "./Toast";
|
||||
export { default as ToastButton } from "./ToastButton";
|
||||
export { default as ToastButtons } from "./ToastButtons";
|
||||
53
scm-ui/ui-components/src/toast/themes.ts
Normal file
53
scm-ui/ui-components/src/toast/themes.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
|
||||
export type ToastTheme = {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
tertiary: string;
|
||||
};
|
||||
|
||||
export type Themeable = {
|
||||
theme: ToastTheme;
|
||||
};
|
||||
|
||||
export type Type = "info" | "primary" | "success" | "warning" | "danger";
|
||||
|
||||
export const types: Type[] = ["info", "primary", "success", "warning", "danger"];
|
||||
|
||||
const themes: { [name in Type]: ToastTheme } = {
|
||||
info: {
|
||||
primary: "#363636",
|
||||
secondary: "#99d8f3",
|
||||
tertiary: "white"
|
||||
},
|
||||
primary: {
|
||||
primary: "#363636",
|
||||
secondary: "#7fe8ef",
|
||||
tertiary: "white"
|
||||
},
|
||||
success: {
|
||||
primary: "#363636",
|
||||
secondary: "#7fe3cd",
|
||||
tertiary: "white"
|
||||
},
|
||||
warning: {
|
||||
primary: "#905515",
|
||||
secondary: "#ffeeab",
|
||||
tertiary: "white"
|
||||
},
|
||||
danger: {
|
||||
primary: "#363636",
|
||||
secondary: "#ff9baf",
|
||||
tertiary: "white"
|
||||
}
|
||||
};
|
||||
|
||||
export const getTheme = (name: Type) => {
|
||||
const theme = themes[name];
|
||||
if (!theme) {
|
||||
throw new Error(`could not find theme with name ${name}`);
|
||||
}
|
||||
return theme;
|
||||
};
|
||||
|
||||
export const ToastThemeContext = React.createContext(themes.warning);
|
||||
33
scm-ui/ui-components/src/usePortalRootElement.ts
Normal file
33
scm-ui/ui-components/src/usePortalRootElement.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const createElement = (id: string) => {
|
||||
const element = document.createElement("div");
|
||||
element.setAttribute("id", id);
|
||||
return element;
|
||||
};
|
||||
|
||||
const appendRootElement = (rootElement: HTMLElement) => {
|
||||
document.body.appendChild(rootElement);
|
||||
};
|
||||
|
||||
const usePortalRootElement = (id: string) => {
|
||||
const [rootElement, setRootElement] = useState<HTMLElement>();
|
||||
useEffect(() => {
|
||||
let element = document.getElementById(id);
|
||||
if (!element) {
|
||||
element = createElement(id);
|
||||
appendRootElement(element);
|
||||
}
|
||||
setRootElement(element);
|
||||
return () => {
|
||||
if (element) {
|
||||
element.remove();
|
||||
}
|
||||
setRootElement(undefined);
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
return rootElement;
|
||||
};
|
||||
|
||||
export default usePortalRootElement;
|
||||
Reference in New Issue
Block a user