Improve widget style (#652)

* Add widget card style and background

* Add padding and header based on embed type parameter

* Add changelog

* Add loading spinner component

* Add loading spinners
This commit is contained in:
Justin Carlson 2023-03-08 09:20:13 -05:00 committed by GitHub
parent 1b80bd8f40
commit 7575f93f67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 212 additions and 78 deletions

1
changelog.d/652.misc Normal file
View File

@ -0,0 +1 @@
Minor improvements to widget UI styles.

View File

@ -1,9 +1,10 @@
/* eslint-disable no-console */
import { Component } from 'preact';
import WA, { MatrixCapabilities } from 'matrix-widget-api';
import { BridgeAPI, BridgeAPIError } from './BridgeAPI';
import { BridgeAPI, BridgeAPIError, EmbedType, embedTypeParameter } from './BridgeAPI';
import { BridgeRoomState } from '../src/Widgets/BridgeWidgetInterface';
import { ErrorPane } from './components/elements';
import { LoadingSpinner } from './components/elements/LoadingSpinner';
import { Card, ErrorPane } from './components/elements';
import AdminSettings from './components/AdminSettings';
import RoomConfigView from './components/RoomConfigView';
@ -19,6 +20,7 @@ interface ICompleteState extends IMinimalState {
[sectionName: string]: boolean;
},
serviceScope?: string,
embedType: EmbedType,
kind: "invite"|"admin"|"roomConfig",
}
@ -55,6 +57,7 @@ export default class App extends Component<void, IState> {
const roomId = assertParam(qs, 'roomId');
const widgetKind = qs.get('kind') as "invite"|"admin"|"roomConfig";
const serviceScope = qs.get('serviceScope');
const embedType = qs.get(embedTypeParameter);
// Fetch via config.
this.widgetApi = new WA.WidgetApi(widgetId);
this.widgetApi.requestCapability(MatrixCapabilities.RequiresClient);
@ -84,6 +87,7 @@ export default class App extends Component<void, IState> {
roomId,
supportedServices,
serviceScope: serviceScope || undefined,
embedType: embedType === EmbedType.IntegrationManager ? EmbedType.IntegrationManager : EmbedType.Default,
kind: widgetKind,
busy: false,
});
@ -110,7 +114,9 @@ export default class App extends Component<void, IState> {
if (this.state.error) {
content = <ErrorPane>{this.state.error}</ErrorPane>;
} else if (this.state.busy) {
content = <div class="spinner" />;
content = <Card>
<LoadingSpinner />
</Card>;
}
if ("kind" in this.state) {
@ -123,6 +129,7 @@ export default class App extends Component<void, IState> {
roomId={this.state.roomId}
supportedServices={this.state.supportedServices}
serviceScope={this.state.serviceScope}
embedType={this.state.embedType}
bridgeApi={this.bridgeApi}
widgetApi={this.widgetApi}
/>;
@ -135,7 +142,9 @@ export default class App extends Component<void, IState> {
}
return (
<div className="app">
<div style={{
padding: this.state.embedType === "integration-manager" ? "0" : "16px",
}}>
{content}
</div>
);

View File

@ -140,7 +140,14 @@ export class BridgeAPI {
}
}
export const embedTypeParameter = 'io_element_embed_type';
export enum EmbedType {
IntegrationManager = 'integration-manager',
Default = 'default',
}
export type BridgeConfig = FunctionComponent<{
api: BridgeAPI,
roomId: string,
showHeader: boolean,
}>;

View File

@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from 'preact/hooks';
import { LoadingSpinner } from "./elements/LoadingSpinner";
import { BridgeRoomState } from "../../src/Widgets/BridgeWidgetInterface";
import GeneralConfig from './configs/GeneralConfig';
import style from "./AdminSettings.module.scss";
@ -36,7 +37,7 @@ export default function AdminSettings(props: IProps) {
);
if (busy) {
return <div class={style.root}>
<div class="spinner" />
<LoadingSpinner />
</div>;
}
return <div class={style.root}>

View File

@ -1,6 +1,6 @@
import { WidgetApi } from "matrix-widget-api";
import { useState } from "preact/hooks"
import { BridgeAPI, BridgeConfig } from "../BridgeAPI";
import { BridgeAPI, BridgeConfig, EmbedType } from "../BridgeAPI";
import style from "./RoomConfigView.module.scss";
import { ConnectionCard } from "./ConnectionCard";
import { FeedsConfig } from "./roomConfig/FeedsConfig";
@ -21,6 +21,7 @@ interface IProps {
bridgeApi: BridgeAPI,
supportedServices: {[service: string]: boolean},
serviceScope?: string,
embedType: EmbedType,
roomId: string,
}
@ -80,7 +81,11 @@ export default function RoomConfigView(props: IProps) {
if (activeConnectionType) {
const ConfigComponent = connections[activeConnectionType].component;
content = <ConfigComponent roomId={props.roomId} api={props.bridgeApi} />;
content = <ConfigComponent
roomId={props.roomId}
api={props.bridgeApi}
showHeader={props.embedType !== EmbedType.IntegrationManager}
/>;
} else {
content = <>
<section>
@ -100,13 +105,13 @@ export default function RoomConfigView(props: IProps) {
}
return <div className={style.root}>
<header>
{!serviceScope && activeConnectionType &&
{!serviceScope && activeConnectionType &&
<header>
<span className={style.backButton} onClick={() => setActiveConnectionType(null)}>
<span className="chevron" /> Browse integrations
</span>
}
</header>
</header>
}
{content}
</div>;
}

View File

@ -0,0 +1,11 @@
.card {
/* Compound/Light/Background */
background: #FFFFFF;
/* Compound/Light/Quinary Content */
border: 1px solid #E3E8F0;
box-sizing: border-box;
border-radius: 8px;
padding: 32px;
}

View File

@ -0,0 +1,11 @@
import React from 'preact';
import styles from './Card.module.scss';
const Card = (props: React.ComponentProps<'div'>) =>
<div
{...props}
className={styles.card}
/>;
export { Card };

View File

@ -0,0 +1,18 @@
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.spinner {
width: 16px;
height: 16px;
animation-name: spin;
animation-duration: 1000ms;
animation-timing-function: steps(8, end);
animation-iteration-count: infinite;
}

View File

@ -0,0 +1,71 @@
import React from 'preact';
import styles from './LoadingSpinner.module.scss';
export const LoadingSpinner = (props: React.ComponentProps<'svg'>) => (
<div className={styles.spinner}>
<svg
width={16}
height={16}
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M6.232 6.232a1 1 0 0 0 0-1.414L4.111 2.697A1 1 0 1 0 2.697 4.11l2.121 2.121a1 1 0 0 0 1.414 0z"
style={{
fill: '#000',
opacity: 0.81,
}}
/>
<path
d="M4.5 7a1 1 0 0 1 0 2h-3a1 1 0 0 1 0-2z"
style={{
fill: '#000',
opacity: 0.69,
}}
/>
<path
d="M6.232 9.768a1 1 0 0 0-1.414 0l-2.121 2.121a1 1 0 1 0 1.414 1.414l2.121-2.121a1 1 0 0 0 0-1.414z"
style={{
fill: '#000',
opacity: 0.57,
}}
/>
<path
d="M7 11.5a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0z"
style={{
fill: '#000',
opacity: 0.45,
}}
/>
<path
d="M13.303 13.303a1 1 0 0 0 0-1.414l-2.121-2.121a1 1 0 0 0-1.414 1.414l2.121 2.121a1 1 0 0 0 1.414 0z"
style={{
fill: '#000',
opacity: 0.32,
}}
/>
<path
d="M14.5 7a1 1 0 1 1 0 2h-3a1 1 0 1 1 0-2z"
style={{
fill: '#000',
opacity: 0.25,
}}
/>
<path
d="M13.303 2.697a1 1 0 0 0-1.414 0L9.768 4.818a1 1 0 1 0 1.414 1.414l2.121-2.121a1 1 0 0 0 0-1.414z"
style={{
fill: '#000',
opacity: 0.12,
}}
/>
<path
d="M8 .5a1 1 0 0 0-1 1v3a1 1 0 0 0 2 0v-3a1 1 0 0 0-1-1Z"
style={{
fill: '#000',
}}
/>
</svg>
</div>
);

View File

@ -1,5 +1,6 @@
export * from "./Button";
export * from "./ButtonSet";
export * from "./Card";
export * from "./ErrorPane";
export * from "./InputField";
export * from "./ListItem";

View File

@ -75,9 +75,10 @@ const RoomConfigText = {
const RoomConfigListItemFunc = (c: FeedResponseItem) => c.config.label || c.config.url;
export const FeedsConfig: BridgeConfig = ({ api, roomId }) => {
export const FeedsConfig: BridgeConfig = ({ api, roomId, showHeader }) => {
return <RoomConfig<ServiceConfig, FeedResponseItem, FeedConnectionState>
headerImg={FeedsIcon}
showHeader={showHeader}
api={api}
roomId={roomId}
type="feeds"

View File

@ -2,7 +2,7 @@ import { FunctionComponent, createRef } from "preact";
import { useCallback, useState } from "preact/hooks"
import CodeMirror from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { BridgeAPI } from "../../BridgeAPI";
import { BridgeConfig } from "../../BridgeAPI";
import { GenericHookConnectionState, GenericHookResponseItem } from "../../../src/Connections/GenericHook";
import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig";
import { InputField, ButtonSet, Button } from "../elements";
@ -73,11 +73,6 @@ const ConnectionConfiguration: FunctionComponent<ConnectionConfigurationProps<Se
</form>;
};
interface IGenericWebhookConfigProps {
api: BridgeAPI,
roomId: string,
}
interface ServiceConfig {
allowJsTransformationFunctions: boolean
}
@ -91,9 +86,10 @@ const RoomConfigText = {
const RoomConfigListItemFunc = (c: GenericHookResponseItem) => c.config.name;
export const GenericWebhookConfig: FunctionComponent<IGenericWebhookConfigProps> = ({ api, roomId }) => {
export const GenericWebhookConfig: BridgeConfig = ({ api, roomId, showHeader }) => {
return <RoomConfig<ServiceConfig, GenericHookResponseItem, GenericHookConnectionState>
headerImg={WebhookIcon}
showHeader={showHeader}
api={api}
roomId={roomId}
type="generic"

View File

@ -135,9 +135,10 @@ const RoomConfigText = {
const RoomConfigListItemFunc = (c: GitHubRepoResponseItem) => getRepoFullName(c.config);
export const GithubRepoConfig: BridgeConfig = ({ api, roomId }) => {
export const GithubRepoConfig: BridgeConfig = ({ api, roomId, showHeader }) => {
return <RoomConfig<never, GitHubRepoResponseItem, GitHubRepoConnectionState>
headerImg={GitHubIcon}
showHeader={showHeader}
api={api}
roomId={roomId}
type="github"

View File

@ -122,9 +122,10 @@ const RoomConfigText = {
const RoomConfigListItemFunc = (c: GitLabRepoResponseItem) => c.config.path;
export const GitlabRepoConfig: BridgeConfig = ({ api, roomId }) => {
export const GitlabRepoConfig: BridgeConfig = ({ api, roomId, showHeader }) => {
return <RoomConfig<never, GitLabRepoResponseItem, GitLabRepoConnectionState>
headerImg={GitLabIcon}
showHeader={showHeader}
api={api}
roomId={roomId}
type="gitlab"

View File

@ -108,9 +108,10 @@ const RoomConfigText = {
const RoomConfigListItemFunc = (c: JiraProjectResponseItem) => c.config.url;
export const JiraProjectConfig: BridgeConfig = ({ api, roomId }) => {
export const JiraProjectConfig: BridgeConfig = ({ api, roomId, showHeader }) => {
return <RoomConfig<never, JiraProjectResponseItem, JiraProjectConnectionState>
headerImg={JiraIcon}
showHeader={showHeader}
api={api}
roomId={roomId}
type="jira"

View File

@ -1,10 +1,11 @@
import { FunctionComponent } from "preact";
import { useCallback, useEffect, useReducer, useState } from "preact/hooks"
import { BridgeAPI, BridgeAPIError } from "../../BridgeAPI";
import { ErrorPane, ListItem, WarningPane } from "../elements";
import { ErrorPane, ListItem, WarningPane, Card } from "../elements";
import style from "./RoomConfig.module.scss";
import { GetConnectionsResponseItem } from "../../../src/provisioning/api";
import { IConnectionState } from "../../../src/Connections";
import { LoadingSpinner } from '../elements/LoadingSpinner';
export interface ConnectionConfigurationProps<SConfig, ConnectionType extends GetConnectionsResponseItem, ConnectionState extends IConnectionState> {
@ -19,6 +20,7 @@ interface IRoomConfigProps<SConfig, ConnectionType extends GetConnectionsRespons
api: BridgeAPI;
roomId: string;
type: string;
showHeader: boolean;
headerImg: string;
text: {
header: string;
@ -32,7 +34,7 @@ interface IRoomConfigProps<SConfig, ConnectionType extends GetConnectionsRespons
}
export const RoomConfig = function<SConfig, ConnectionType extends GetConnectionsResponseItem, ConnectionState extends IConnectionState>(props: IRoomConfigProps<SConfig, ConnectionType, ConnectionState>) {
const { api, roomId, type, headerImg, text, listItemName, connectionEventType } = props;
const { api, roomId, type, headerImg, showHeader, text, listItemName, connectionEventType } = props;
const ConnectionConfigComponent = props.connectionConfigComponent;
const [ error, setError ] = useState<null|{header?: string, message: string, isWarning?: boolean, forPrevious?: boolean}>(null);
const [ connections, setConnections ] = useState<ConnectionType[]|null>(null);
@ -91,30 +93,34 @@ export const RoomConfig = function<SConfig, ConnectionType extends GetConnection
});
}, [api, roomId, connectionEventType]);
return <main>
{
error &&
return <Card>
<main>
{
error &&
(!error.isWarning
? <ErrorPane header={error.header || "Error"}>{error.message}</ErrorPane>
: <WarningPane header={error.header || "Warning"}>{error.message}</WarningPane>
? <ErrorPane header={error.header || "Error"}>{error.message}</ErrorPane>
: <WarningPane header={error.header || "Warning"}>{error.message}</WarningPane>
)
}
<header className={style.header}>
<img alt="" src={headerImg} />
<h1>{text.header}</h1>
</header>
{ canEditRoom && <section>
<h2>{text.createNew}</h2>
{serviceConfig && <ConnectionConfigComponent
key={newConnectionKey}
api={api}
serviceConfig={serviceConfig}
onSave={handleSaveOnCreation}
/>}
</section>}
{ !!connections?.length && <section>
<h2>{ canEditRoom ? text.listCanEdit : text.listCantEdit }</h2>
{ serviceConfig && connections?.map(c => <ListItem key={c.id} text={listItemName(c)}>
}
{ showHeader &&
<header className={style.header}>
<img alt="" src={headerImg} />
<h1>{text.header}</h1>
</header>
}
{ canEditRoom && <section>
<h2>{text.createNew}</h2>
{serviceConfig && <ConnectionConfigComponent
key={newConnectionKey}
api={api}
serviceConfig={serviceConfig}
onSave={handleSaveOnCreation}
/>}
</section>}
{ connections === null && <LoadingSpinner /> }
{ !!connections?.length && <section>
<h2>{ canEditRoom ? text.listCanEdit : text.listCantEdit }</h2>
{ serviceConfig && connections?.map(c => <ListItem key={c.id} text={listItemName(c)}>
<ConnectionConfigComponent
api={api}
serviceConfig={serviceConfig}
@ -147,7 +153,8 @@ export const RoomConfig = function<SConfig, ConnectionType extends GetConnection
}}
/>
</ListItem>)
}
</section>}
</main>;
}
</section>}
</main>
</Card>;
};

View File

@ -1,11 +1,15 @@
:root {
--background-color: #FFFFFF;
--foreground-color: #17191C;
--light-color: #737D8C;
--primary-color: #0DBD8B;
--primary-color-disabled: #0dbd8baf;
background-color: #F4F6FA;
color: var(--foreground-color);
min-height: 100%;
width: 100%;
font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji';
}
// @media (prefers-color-scheme: dark) {
@ -15,18 +19,6 @@
// }
// }
#root {
background-color: var(--background-color);
color: var(--foreground-color);
min-height: 100vh;
width: 100vw;
font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji';
}
.app {
padding: 32px;
}
body {
margin: 0;
}