You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

354 lines
14 KiB

import React, { useState, useEffect } from 'react';
import { Box, CircularProgress, IconButton, Typography, MenuItem, TextField, Menu, Button, Card, CardHeader, CardContent, CardActions, Dialog, DialogTitle } from '@material-ui/core';
import { getIntegrations, createIntegration, modifyIntegration, deleteIntegration } from '../../../lib/backend/integrations';
import AddIcon from '@material-ui/icons/Add';
import EditIcon from '@material-ui/icons/Edit';
import CheckIcon from '@material-ui/icons/Check';
import DeleteIcon from '@material-ui/icons/Delete';
import ClearIcon from '@material-ui/icons/Clear';
import * as serverApi from '../../../api/api';
import { v4 as genUuid } from 'uuid';
import { useIntegrations, IntegrationClasses, IntegrationState, isIntegrationState, makeDefaultIntegrationProperties, makeIntegration } from '../../../lib/integration/useIntegrations';
import Alert from '@material-ui/lab/Alert';
import Integration from '../../../lib/integration/Integration';
let _ = require('lodash')
// This widget is used to either display or edit a few
// specifically needed for Spotify Client credentials integration.
function EditSpotifyClientCredentialsDetails(props: {
clientId: string,
clientSecret: string | null,
editing: boolean,
onChangeClientId: (v: string) => void,
onChangeClientSecret: (v: string) => void,
}) {
return <Box>
<Box mt={1} mb={1}>
<TextField
variant="outlined"
disabled={!props.editing}
value={props.clientId || ""}
label="Client id"
fullWidth
onChange={(e: any) => props.onChangeClientId(e.target.value)}
/>
</Box>
<Box mt={1} mb={1}>
<TextField
variant="outlined"
disabled={!props.editing}
value={props.clientSecret === null ? "••••••••••••••••" : props.clientSecret}
label="Client secret"
fullWidth
onChange={(e: any) => {
props.onChangeClientSecret(e.target.value)
}}
onFocus={(e: any) => {
if (props.clientSecret === null) {
// Change from dots to empty input
console.log("Focus!")
props.onChangeClientSecret('');
}
}}
/>
</Box>
</Box>;
}
// An editing widget which is meant to either display or edit properties
// of an integration.
function EditIntegration(props: {
upstreamId?: number,
integration: serverApi.PostIntegrationRequest,
editing?: boolean,
showSubmitButton?: boolean | "InProgress",
showDeleteButton?: boolean | "InProgress",
showEditButton?: boolean,
showTestButton?: boolean | "InProgress",
showCancelButton?: boolean,
flashMessage?: React.ReactFragment,
isNew: boolean,
onChange?: (p: serverApi.PostIntegrationRequest) => void,
onSubmit?: (p: serverApi.PostIntegrationRequest) => void,
onDelete?: () => void,
onEdit?: () => void,
onTest?: () => void,
onCancel?: () => void,
}) {
let IntegrationHeaders: Record<any, any> = {
[serverApi.IntegrationImpl.SpotifyClientCredentials]:
<Box display="flex" alignItems="center">
<Box mr={1}>
{new IntegrationClasses[serverApi.IntegrationImpl.SpotifyClientCredentials](-1).getIcon({
style: { height: '40px', width: '40px' }
})}
</Box>
<Typography>Spotify (using Client Credentials)</Typography>
</Box>,
[serverApi.IntegrationImpl.YoutubeWebScraper]:
<Box display="flex" alignItems="center">
<Box mr={1}>
{new IntegrationClasses[serverApi.IntegrationImpl.YoutubeWebScraper](-1).getIcon({
style: { height: '40px', width: '40px' }
})}
</Box>
<Typography>Youtube Music (using experimental web scraper)</Typography>
</Box>,
}
let IntegrationDescription: Record<any, any> = {
[serverApi.IntegrationImpl.SpotifyClientCredentials]:
<Typography>
This integration allows using the Spotify API to make requests that are
tied to any specific user, such as searching items and retrieving item
metadata.<br />
Please see the Spotify API documentation on how to generate a client ID
and client secret. Once set, you will only be able to overwrite the secret
here, not read it.
</Typography>,
[serverApi.IntegrationImpl.YoutubeWebScraper]:
<Typography>
This integration allows using the public Youtube Music search page to scrape
for music metadata. <br />
Because it relies on reverse-engineering of a web page that may change in the
future, this is considered to be experimental and unstable. However, the music links acquired
using this method are expected to remain reasonably stable.
</Typography>,
}
return <Card variant="outlined">
<CardHeader
avatar={
IntegrationHeaders[props.integration.type]
}
>
</CardHeader>
<CardContent>
<Box mb={2}>{IntegrationDescription[props.integration.type]}</Box>
<Box mt={1} mb={1}>
<TextField
variant="outlined"
value={props.integration.name || ""}
label="Integration name"
fullWidth
disabled={!props.editing}
onChange={(e: any) => props.onChange && props.onChange({
...props.integration,
name: e.target.value,
})}
/>
</Box>
{props.integration.type === serverApi.IntegrationImpl.SpotifyClientCredentials &&
<EditSpotifyClientCredentialsDetails
clientId={'clientId' in props.integration.details &&
props.integration.details.clientId || ""}
clientSecret={props.integration.secretDetails && 'clientSecret' in props.integration.secretDetails ?
props.integration.secretDetails.clientSecret :
(props.isNew ? "" : null)}
editing={props.editing || false}
onChangeClientId={(v: string) => props.onChange && props.onChange({
...props.integration,
details: {
...props.integration.details,
clientId: v,
}
})}
onChangeClientSecret={(v: string) => props.onChange && props.onChange({
...props.integration,
secretDetails: {
...props.integration.secretDetails,
clientSecret: v,
}
})}
/>
}
{props.flashMessage && props.flashMessage}
</CardContent>
<CardActions>
{props.showEditButton && <IconButton
onClick={props.onEdit}
><EditIcon /></IconButton>}
{props.showSubmitButton && <IconButton
onClick={() => props.onSubmit && props.onSubmit(props.integration)}
><CheckIcon /></IconButton>}
{props.showDeleteButton && <IconButton
onClick={props.onDelete}
><DeleteIcon /></IconButton>}
{props.showCancelButton && <IconButton
onClick={props.onCancel}
><ClearIcon /></IconButton>}
{props.showTestButton && <Button
onClick={props.onTest}
>Test</Button>}
</CardActions>
</Card>
}
let EditorWithTest = (props: any) => {
const [testFlashMessage, setTestFlashMessage] =
React.useState<React.ReactFragment | undefined>(undefined);
let { integration, ...rest } = props;
return <EditIntegration
onTest={() => {
integration.integration.test({})
.then(() => {
setTestFlashMessage(
<Alert severity="success">Integration is active.</Alert>
)
})
}}
flashMessage={testFlashMessage}
showTestButton={true}
integration={integration.properties}
{...rest}
/>;
}
function AddIntegrationMenu(props: {
position: null | number[],
open: boolean,
onClose?: () => void,
onAdd?: (type: serverApi.IntegrationImpl) => void,
}) {
const pos = props.open && props.position ?
{ left: props.position[0], top: props.position[1] }
: { left: 0, top: 0 }
return <Menu
open={props.open}
anchorReference="anchorPosition"
anchorPosition={pos}
keepMounted
onClose={props.onClose}
>
<MenuItem
onClick={() => {
props.onAdd && props.onAdd(serverApi.IntegrationImpl.SpotifyClientCredentials);
props.onClose && props.onClose();
}}
>Spotify via Client Credentials</MenuItem>
<MenuItem
onClick={() => {
props.onAdd && props.onAdd(serverApi.IntegrationImpl.YoutubeWebScraper);
props.onClose && props.onClose();
}}
>Youtube Music Web Scraper</MenuItem>
</Menu>
}
function EditIntegrationDialog(props: {
open: boolean,
onClose?: () => void,
upstreamId?: number,
integration: IntegrationState,
onSubmit?: (p: serverApi.PostIntegrationRequest) => void,
isNew: boolean,
}) {
let [editingIntegration, setEditingIntegration] =
useState<IntegrationState>(props.integration);
useEffect(() => { setEditingIntegration(props.integration); }, [props.integration]);
return <Dialog
onClose={props.onClose}
open={props.open}
disableBackdropClick={true}
>
<DialogTitle>Edit Integration</DialogTitle>
<EditIntegration
isNew={props.isNew}
editing={true}
upstreamId={props.upstreamId}
integration={editingIntegration.properties}
showCancelButton={true}
showSubmitButton={props.onSubmit !== undefined}
showTestButton={false}
onCancel={props.onClose}
onSubmit={props.onSubmit}
onChange={(i: any) => {
setEditingIntegration({
...editingIntegration,
properties: i,
integration: makeIntegration(i, editingIntegration.id),
});
}}
/>
</Dialog>
}
export default function IntegrationSettings(props: {}) {
const [addMenuPos, setAddMenuPos] = React.useState<null | number[]>(null);
const [editingState, setEditingState] = React.useState<IntegrationState | null>(null);
let {
state: integrations,
addIntegration,
modifyIntegration,
deleteIntegration,
updateFromUpstream,
} = useIntegrations();
const onOpenAddMenu = (e: any) => {
setAddMenuPos([e.clientX, e.clientY])
};
const onCloseAddMenu = () => {
setAddMenuPos(null);
};
return <>
<Box>
{integrations === null && <CircularProgress />}
{Array.isArray(integrations) && <Box display="flex" flexDirection="column" alignItems="center" flexWrap="wrap">
{integrations.map((state: IntegrationState) => <Box m={1} width="90%">
<EditorWithTest
upstreamId={state.id}
integration={state}
showEditButton={true}
showDeleteButton={true}
onEdit={() => { setEditingState(state); }}
onDelete={() => {
deleteIntegration(state.id)
.then(updateFromUpstream)
}}
/>
</Box>)}
<IconButton onClick={onOpenAddMenu}>
<AddIcon />
</IconButton>
</Box>}
</Box>
<AddIntegrationMenu
position={addMenuPos}
open={addMenuPos !== null}
onClose={onCloseAddMenu}
onAdd={(type: serverApi.IntegrationImpl) => {
let p = makeDefaultIntegrationProperties(type);
setEditingState({
properties: p,
integration: makeIntegration(p, -1),
id: -1,
})
}}
/>
{editingState && <EditIntegrationDialog
open={!(editingState === null)}
onClose={() => { setEditingState(null); }}
integration={editingState}
isNew={editingState.id === -1}
onSubmit={(v: serverApi.PostIntegrationRequest) => {
if (editingState.id >= 0) {
const id = editingState.id;
setEditingState(null);
modifyIntegration(id, v)
.then(updateFromUpstream)
} else {
setEditingState(null);
createIntegration({
...v,
secretDetails: v.secretDetails || {},
})
.then(updateFromUpstream)
}
}}
/>}
</>;
}