Merged in feature/assign_ui_session_id (pull request #377)

API-Client Support for SSE and Toast Components
This commit is contained in:
Eduard Heimbuch
2019-12-13 12:21:07 +00:00
27 changed files with 2145 additions and 1084 deletions

View File

@@ -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`;

View File

@@ -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();

View File

@@ -66,6 +66,7 @@ export * from "./modals";
export * from "./navigation";
export * from "./repos";
export * from "./table";
export * from "./toast";
export {
File,

View 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))}

View 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;

View 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;

View 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;

View 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>
));
});

View File

@@ -0,0 +1,3 @@
export { default as Toast } from "./Toast";
export { default as ToastButton } from "./ToastButton";
export { default as ToastButtons } from "./ToastButtons";

View 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);

View 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;