parent
f369d4e390
commit
d8438ec3c1
22 changed files with 785 additions and 440 deletions
@ -0,0 +1,330 @@ |
|||||||
|
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'; |
||||||
|
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.CreateIntegrationRequest, |
||||||
|
editing?: boolean, |
||||||
|
showSubmitButton?: boolean | "InProgress", |
||||||
|
showDeleteButton?: boolean | "InProgress", |
||||||
|
showEditButton?: boolean, |
||||||
|
showTestButton?: boolean | "InProgress", |
||||||
|
showCancelButton?: boolean, |
||||||
|
flashMessage?: React.ReactFragment, |
||||||
|
isNew: boolean, |
||||||
|
onChange?: (p: serverApi.CreateIntegrationRequest) => void, |
||||||
|
onSubmit?: (p: serverApi.CreateIntegrationRequest) => void, |
||||||
|
onDelete?: () => void, |
||||||
|
onEdit?: () => void, |
||||||
|
onTest?: () => void, |
||||||
|
onCancel?: () => void, |
||||||
|
}) { |
||||||
|
let IntegrationHeaders: Record<any, any> = { |
||||||
|
[serverApi.IntegrationType.SpotifyClientCredentials]: |
||||||
|
<Box display="flex" alignItems="center"> |
||||||
|
<Box mr={1}> |
||||||
|
{IntegrationClasses[serverApi.IntegrationType.SpotifyClientCredentials].getIcon({ |
||||||
|
style: { height: '40px', width: '40px' } |
||||||
|
})} |
||||||
|
</Box> |
||||||
|
<Typography>Spotify (using Client Credentials)</Typography> |
||||||
|
</Box> |
||||||
|
} |
||||||
|
let IntegrationDescription: Record<any, any> = { |
||||||
|
[serverApi.IntegrationType.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> |
||||||
|
} |
||||||
|
|
||||||
|
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.IntegrationType.SpotifyClientCredentials && |
||||||
|
<EditSpotifyClientCredentialsDetails |
||||||
|
clientId={props.integration.details.clientId} |
||||||
|
clientSecret={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.IntegrationType) => 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.IntegrationType.SpotifyClientCredentials); |
||||||
|
props.onClose && props.onClose(); |
||||||
|
}} |
||||||
|
>Spotify</MenuItem> |
||||||
|
</Menu> |
||||||
|
} |
||||||
|
|
||||||
|
function EditIntegrationDialog(props: { |
||||||
|
open: boolean, |
||||||
|
onClose?: () => void, |
||||||
|
upstreamId?: number, |
||||||
|
integration: IntegrationState, |
||||||
|
onSubmit?: (p: serverApi.CreateIntegrationRequest) => 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.IntegrationType) => { |
||||||
|
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.CreateIntegrationRequest) => { |
||||||
|
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) |
||||||
|
} |
||||||
|
}} |
||||||
|
/>} |
||||||
|
</>; |
||||||
|
} |
@ -1,352 +0,0 @@ |
|||||||
import React, { useState, useEffect, useCallback } from 'react'; |
|
||||||
import { useAuth } from '../../../lib/useAuth'; |
|
||||||
import { Box, CircularProgress, IconButton, Typography, FormControl, Select, MenuItem, TextField, Menu, Button, Card, CardHeader, CardContent, CardActions } 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 * as serverApi from '../../../api'; |
|
||||||
import StoreLinkIcon, { ExternalStore } from '../../common/StoreLinkIcon'; |
|
||||||
import { v4 as genUuid } from 'uuid'; |
|
||||||
import { testSpotify } from '../../../lib/integration/spotify/spotifyClientCreds'; |
|
||||||
let _ = require('lodash') |
|
||||||
|
|
||||||
interface EditorIntegrationState extends serverApi.IntegrationDetailsResponse { |
|
||||||
secretDetails?: any, |
|
||||||
} |
|
||||||
|
|
||||||
interface EditIntegrationProps { |
|
||||||
upstreamId: number | null, |
|
||||||
integration: EditorIntegrationState, |
|
||||||
original: EditorIntegrationState, |
|
||||||
editing: boolean, |
|
||||||
submitting: boolean, |
|
||||||
onChange: (p: EditorIntegrationState, editing: boolean) => void, |
|
||||||
onSubmit: () => void, |
|
||||||
onDelete: () => void, |
|
||||||
} |
|
||||||
|
|
||||||
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>; |
|
||||||
} |
|
||||||
|
|
||||||
function EditIntegration(props: EditIntegrationProps) { |
|
||||||
let IntegrationHeaders: Record<any, any> = { |
|
||||||
[serverApi.IntegrationType.SpotifyClientCredentials]: |
|
||||||
<Box display="flex" alignItems="center"> |
|
||||||
<Box mr={1}><StoreLinkIcon |
|
||||||
style={{ height: '40px', width: '40px' }} |
|
||||||
whichStore={ExternalStore.Spotify} |
|
||||||
/></Box> |
|
||||||
<Typography>Spotify (using Client Credentials)</Typography> |
|
||||||
</Box> |
|
||||||
} |
|
||||||
let IntegrationDescription: Record<any, any> = { |
|
||||||
[serverApi.IntegrationType.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> |
|
||||||
} |
|
||||||
|
|
||||||
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.integration, |
|
||||||
name: e.target.value, |
|
||||||
}, props.editing)} |
|
||||||
/> |
|
||||||
</Box> |
|
||||||
{props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials && |
|
||||||
<EditSpotifyClientCredentialsDetails |
|
||||||
clientId={props.integration.details.clientId} |
|
||||||
clientSecret={ |
|
||||||
(props.integration.secretDetails && |
|
||||||
props.integration.secretDetails.clientSecret !== null) ? |
|
||||||
props.integration.secretDetails.clientSecret : null} |
|
||||||
editing={props.editing} |
|
||||||
onChangeClientId={(v: string) => props.onChange({ |
|
||||||
...props.integration, |
|
||||||
details: { |
|
||||||
...props.integration.details, |
|
||||||
clientId: v, |
|
||||||
} |
|
||||||
}, props.editing)} |
|
||||||
onChangeClientSecret={(v: string) => props.onChange({ |
|
||||||
...props.integration, |
|
||||||
secretDetails: { |
|
||||||
...props.integration.secretDetails, |
|
||||||
clientSecret: v, |
|
||||||
} |
|
||||||
}, props.editing)} |
|
||||||
/> |
|
||||||
} |
|
||||||
</CardContent> |
|
||||||
<CardActions> |
|
||||||
{!props.editing && !props.submitting && <IconButton |
|
||||||
onClick={() => { props.onChange(props.integration, true); }} |
|
||||||
><EditIcon /></IconButton>} |
|
||||||
{props.editing && !props.submitting && <IconButton |
|
||||||
onClick={() => { props.onSubmit(); }} |
|
||||||
><CheckIcon /></IconButton>} |
|
||||||
{!props.submitting && <IconButton |
|
||||||
onClick={() => { props.onDelete(); }} |
|
||||||
><DeleteIcon /></IconButton>} |
|
||||||
{!props.submitting && !props.editing && props.upstreamId !== null && <Button |
|
||||||
onClick={() => testSpotify(props.upstreamId || 0)} |
|
||||||
>Test</Button>} |
|
||||||
{props.submitting && <CircularProgress />} |
|
||||||
</CardActions> |
|
||||||
</Card> |
|
||||||
} |
|
||||||
|
|
||||||
function AddIntegrationMenu(props: { |
|
||||||
position: null | number[], |
|
||||||
open: boolean, |
|
||||||
onClose: () => void, |
|
||||||
onAdd: (type: serverApi.IntegrationType) => 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(serverApi.IntegrationType.SpotifyClientCredentials); |
|
||||||
props.onClose(); |
|
||||||
}} |
|
||||||
>Spotify</MenuItem> |
|
||||||
</Menu> |
|
||||||
} |
|
||||||
|
|
||||||
export default function IntegrationSettingsEditor(props: {}) { |
|
||||||
interface EditorState { |
|
||||||
id: string, //uniquely identifies this editor in the window.
|
|
||||||
upstreamId: number | null, //back-end ID for this integration if any.
|
|
||||||
integration: EditorIntegrationState, |
|
||||||
original: EditorIntegrationState, |
|
||||||
editing: boolean, |
|
||||||
submitting: boolean, |
|
||||||
} |
|
||||||
let [editors, setEditors] = useState<EditorState[] | null>(null); |
|
||||||
const [addMenuPos, setAddMenuPos] = React.useState<null | number[]>(null); |
|
||||||
|
|
||||||
const onOpenAddMenu = (e: any) => { |
|
||||||
setAddMenuPos([e.clientX, e.clientY]) |
|
||||||
}; |
|
||||||
const onCloseAddMenu = () => { |
|
||||||
setAddMenuPos(null); |
|
||||||
}; |
|
||||||
|
|
||||||
const submitEditor = (state: EditorState) => { |
|
||||||
let integration: any = state.integration; |
|
||||||
|
|
||||||
if (state.upstreamId === null) { |
|
||||||
if (!state.integration.secretDetails) { |
|
||||||
throw new Error('Cannot create an integration without its secret details set.') |
|
||||||
} |
|
||||||
createIntegration(integration).then((response: any) => { |
|
||||||
if (!response.id) { |
|
||||||
throw new Error('failed to submit integration.') |
|
||||||
} |
|
||||||
let cpy = _.cloneDeep(editors); |
|
||||||
cpy.forEach((s: any) => { |
|
||||||
if (s.id === state.id) { |
|
||||||
s.submitting = false; |
|
||||||
s.editing = false; |
|
||||||
s.upstreamId = response.id; |
|
||||||
} |
|
||||||
}) |
|
||||||
setEditors(cpy); |
|
||||||
}) |
|
||||||
} else { |
|
||||||
modifyIntegration(state.upstreamId, integration).then(() => { |
|
||||||
let cpy = _.cloneDeep(editors); |
|
||||||
cpy.forEach((s: any) => { |
|
||||||
if (s.id === state.id) { |
|
||||||
s.submitting = false; |
|
||||||
s.editing = false; |
|
||||||
} |
|
||||||
}) |
|
||||||
setEditors(cpy); |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const deleteEditor = (state: EditorState) => { |
|
||||||
let promise: Promise<void> = state.upstreamId ? |
|
||||||
deleteIntegration(state.upstreamId) : |
|
||||||
(async () => { })(); |
|
||||||
|
|
||||||
promise.then((response: any) => { |
|
||||||
let cpy = _.cloneDeep(editors).filter( |
|
||||||
(e: any) => e.id !== state.id |
|
||||||
); |
|
||||||
setEditors(cpy); |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
getIntegrations() |
|
||||||
.then((integrations: serverApi.ListIntegrationsResponse) => { |
|
||||||
setEditors(integrations.map((i: any, idx: any) => { |
|
||||||
return { |
|
||||||
integration: { ...i }, |
|
||||||
original: { ...i }, |
|
||||||
id: genUuid(), |
|
||||||
editing: false, |
|
||||||
submitting: false, |
|
||||||
upstreamId: i.id, |
|
||||||
} |
|
||||||
})); |
|
||||||
}); |
|
||||||
}, []); |
|
||||||
|
|
||||||
return <> |
|
||||||
<Box> |
|
||||||
{editors === null && <CircularProgress />} |
|
||||||
{editors && <Box display="flex" flexDirection="column" alignItems="center" flexWrap="wrap"> |
|
||||||
{editors.map((state: EditorState) => <Box m={1} width="90%"> |
|
||||||
<EditIntegration |
|
||||||
upstreamId={state.upstreamId} |
|
||||||
integration={state.integration} |
|
||||||
original={state.original} |
|
||||||
editing={state.editing} |
|
||||||
submitting={state.submitting} |
|
||||||
onChange={(p: EditorIntegrationState, editing: boolean) => { |
|
||||||
if (!editors) { |
|
||||||
throw new Error('cannot change editors before loading integrations.') |
|
||||||
} |
|
||||||
let cpy: EditorState[] = _.cloneDeep(editors); |
|
||||||
cpy.forEach((s: any) => { |
|
||||||
if (s.id === state.id) { |
|
||||||
s.integration = p; |
|
||||||
s.editing = editing; |
|
||||||
} |
|
||||||
}) |
|
||||||
setEditors(cpy); |
|
||||||
}} |
|
||||||
onSubmit={() => { |
|
||||||
if (!editors) { |
|
||||||
throw new Error('cannot submit editors before loading integrations.') |
|
||||||
} |
|
||||||
let cpy: EditorState[] = _.cloneDeep(editors); |
|
||||||
cpy.forEach((s: EditorState) => { |
|
||||||
if (s.id === state.id) { |
|
||||||
s.submitting = true; |
|
||||||
s.integration.secretDetails = undefined; |
|
||||||
} |
|
||||||
}) |
|
||||||
setEditors(cpy); |
|
||||||
submitEditor(state); |
|
||||||
}} |
|
||||||
onDelete={() => { |
|
||||||
if (!editors) { |
|
||||||
throw new Error('cannot submit editors before loading integrations.') |
|
||||||
} |
|
||||||
let cpy: EditorState[] = _.cloneDeep(editors); |
|
||||||
cpy.forEach((s: any) => { |
|
||||||
if (s.id === state.id) { |
|
||||||
s.submitting = true; |
|
||||||
} |
|
||||||
}) |
|
||||||
setEditors(cpy); |
|
||||||
deleteEditor(state); |
|
||||||
}} |
|
||||||
/> |
|
||||||
</Box>)} |
|
||||||
<IconButton onClick={onOpenAddMenu}> |
|
||||||
<AddIcon /> |
|
||||||
</IconButton> |
|
||||||
</Box>} |
|
||||||
</Box> |
|
||||||
<AddIntegrationMenu |
|
||||||
position={addMenuPos} |
|
||||||
open={addMenuPos !== null} |
|
||||||
onClose={onCloseAddMenu} |
|
||||||
onAdd={(type: serverApi.IntegrationType) => { |
|
||||||
let cpy = _.cloneDeep(editors); |
|
||||||
cpy.push({ |
|
||||||
integration: { |
|
||||||
type: serverApi.IntegrationType.SpotifyClientCredentials, |
|
||||||
details: { |
|
||||||
clientId: '', |
|
||||||
}, |
|
||||||
secretDetails: { |
|
||||||
clientSecret: '', |
|
||||||
}, |
|
||||||
name: '', |
|
||||||
}, |
|
||||||
original: null, |
|
||||||
id: genUuid(), |
|
||||||
editing: true, |
|
||||||
submitting: false, |
|
||||||
upstreamId: null, |
|
||||||
}) |
|
||||||
setEditors(cpy); |
|
||||||
}} |
|
||||||
/> |
|
||||||
</>; |
|
||||||
} |
|
@ -0,0 +1,24 @@ |
|||||||
|
import { ResponsiveFontSizesOptions } from "@material-ui/core/styles/responsiveFontSizes"; |
||||||
|
import { useHistory } from "react-router"; |
||||||
|
import { Auth } from "../useAuth"; |
||||||
|
|
||||||
|
export class NotLoggedInError extends Error { |
||||||
|
constructor(message: string) { |
||||||
|
super(message); |
||||||
|
this.name = "NotLoggedInError"; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export default async function backendRequest(url: any, ...restArgs: any[]): Promise<Response> { |
||||||
|
let response = await fetch(url, ...restArgs); |
||||||
|
if (response.status === 401 && (await response.json()).reason === "NotLoggedIn") { |
||||||
|
console.log("Not logged in!") |
||||||
|
throw new NotLoggedInError("Not logged in."); |
||||||
|
} |
||||||
|
return response; |
||||||
|
} |
||||||
|
|
||||||
|
export function handleNotLoggedIn(auth: Auth, e: NotLoggedInError) { |
||||||
|
console.log("Not logged in!") |
||||||
|
auth.signout(); |
||||||
|
} |
@ -0,0 +1,59 @@ |
|||||||
|
import React, { ReactFragment } from 'react'; |
||||||
|
|
||||||
|
export interface IntegrationAlbum { |
||||||
|
name?: string, |
||||||
|
artist?: IntegrationArtist, |
||||||
|
url?: string, // An URL to access the item externally.
|
||||||
|
} |
||||||
|
|
||||||
|
export interface IntegrationArtist { |
||||||
|
name?: string, |
||||||
|
url?: string, // An URL to access the item externally.
|
||||||
|
} |
||||||
|
|
||||||
|
export interface IntegrationSong { |
||||||
|
title?: string, |
||||||
|
album?: IntegrationAlbum, |
||||||
|
artist?: IntegrationArtist, |
||||||
|
url?: string, // An URL to access the item externally.
|
||||||
|
} |
||||||
|
|
||||||
|
export enum IntegrationFeature { |
||||||
|
// Used to test whether the integration is active.
|
||||||
|
Test = 0, |
||||||
|
|
||||||
|
// Used to get a bucket of songs (typically: the whole library)
|
||||||
|
GetSongs, |
||||||
|
|
||||||
|
// Used to search items and get some amount of candidate results.
|
||||||
|
SearchSong, |
||||||
|
SearchAlbum, |
||||||
|
SearchArtist, |
||||||
|
} |
||||||
|
|
||||||
|
export interface IntegrationDescriptor { |
||||||
|
supports: IntegrationFeature[], |
||||||
|
} |
||||||
|
|
||||||
|
export default class Integration { |
||||||
|
constructor(integrationId: number) { } |
||||||
|
|
||||||
|
// Common
|
||||||
|
static getFeatures(): IntegrationFeature[] { return []; } |
||||||
|
static getIcon(props: any): ReactFragment { return <></> } |
||||||
|
|
||||||
|
// Requires feature: Test
|
||||||
|
async test(testParams: any): Promise<void> {} |
||||||
|
|
||||||
|
// Requires feature: GetSongs
|
||||||
|
async getSongs(getSongsParams: any): Promise<IntegrationSong[]> { return []; } |
||||||
|
|
||||||
|
// Requires feature: SearchSongs
|
||||||
|
async searchSong(songProps: IntegrationSong): Promise<IntegrationSong[]> { return []; } |
||||||
|
|
||||||
|
// Requires feature: SearchAlbum
|
||||||
|
async searchAlbum(albumProps: IntegrationAlbum): Promise<IntegrationAlbum[]> { return []; } |
||||||
|
|
||||||
|
// Requires feature: SearchArtist
|
||||||
|
async searchArtist(artistProps: IntegrationArtist): Promise<IntegrationArtist[]> { return []; } |
||||||
|
} |
@ -0,0 +1,95 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import Integration, { IntegrationFeature, IntegrationAlbum, IntegrationArtist, IntegrationSong } from '../Integration'; |
||||||
|
import StoreLinkIcon, { ExternalStore } from '../../../components/common/StoreLinkIcon'; |
||||||
|
|
||||||
|
enum SearchType { |
||||||
|
Song = 'song', |
||||||
|
Artist = 'artist', |
||||||
|
Album = 'album', |
||||||
|
}; |
||||||
|
|
||||||
|
export default class SpotifyClientCreds extends Integration { |
||||||
|
integrationId: number; |
||||||
|
|
||||||
|
constructor(integrationId: number) { |
||||||
|
super(integrationId); |
||||||
|
this.integrationId = integrationId; |
||||||
|
} |
||||||
|
|
||||||
|
static getFeatures(): IntegrationFeature[] { |
||||||
|
return [ |
||||||
|
IntegrationFeature.Test, |
||||||
|
IntegrationFeature.SearchSong, |
||||||
|
IntegrationFeature.SearchAlbum, |
||||||
|
IntegrationFeature.SearchArtist, |
||||||
|
] |
||||||
|
} |
||||||
|
|
||||||
|
static getIcon(props: any) { |
||||||
|
return <StoreLinkIcon whichStore={ExternalStore.Spotify} {...props} /> |
||||||
|
} |
||||||
|
|
||||||
|
async test(testParams: {}) { |
||||||
|
const response = await fetch( |
||||||
|
(process.env.REACT_APP_BACKEND || "") + |
||||||
|
`/integrations/${this.integrationId}/v1/search?q=queens&type=artist`); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error("Spttify Client Credentails test failed: " + JSON.stringify(response)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async searchSong(songProps: IntegrationSong): Promise<IntegrationSong[]> { return []; } |
||||||
|
async searchAlbum(albumProps: IntegrationAlbum): Promise<IntegrationAlbum[]> { return []; } |
||||||
|
async searchArtist(artistProps: IntegrationArtist): Promise<IntegrationArtist[]> { return []; } |
||||||
|
|
||||||
|
async search(query: string, type: SearchType): |
||||||
|
Promise<IntegrationSong[] | IntegrationAlbum[] | IntegrationArtist[]> { |
||||||
|
const response = await fetch( |
||||||
|
(process.env.REACT_APP_BACKEND || "") + |
||||||
|
`/integrations/${this.integrationId}/v1/search?q=${encodeURIComponent(query)}&type=${type}`); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error("Spotify Client Credentails search failed: " + JSON.stringify(response)); |
||||||
|
} |
||||||
|
|
||||||
|
switch(type) { |
||||||
|
case SearchType.Song: { |
||||||
|
return (await response.json()).tracks.items.map((r: any): IntegrationSong => { |
||||||
|
return { |
||||||
|
title: r.name, |
||||||
|
url: r.external_urls.spotify, |
||||||
|
artist: { |
||||||
|
name: r.artists[0].name, |
||||||
|
url: r.artists[0].external_urls.spotify, |
||||||
|
}, |
||||||
|
album: { |
||||||
|
name: r.albums[0].name, |
||||||
|
url: r.albums[0].external_urls.spotify, |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
case SearchType.Artist: { |
||||||
|
return (await response.json()).artists.items.map((r: any): IntegrationArtist => { |
||||||
|
return { |
||||||
|
name: r.name, |
||||||
|
url: r.external_urls.spotify, |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
case SearchType.Album: { |
||||||
|
return (await response.json()).albums.items.map((r: any): IntegrationAlbum => { |
||||||
|
return { |
||||||
|
name: r.name, |
||||||
|
url: r.external_urls.spotify, |
||||||
|
artist: { |
||||||
|
name: r.artists[0].name, |
||||||
|
url: r.artists[0].external_urls.spotify, |
||||||
|
}, |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -1,14 +0,0 @@ |
|||||||
export async function testSpotify(integrationId: number) { |
|
||||||
const requestOpts = { |
|
||||||
method: 'GET', |
|
||||||
}; |
|
||||||
|
|
||||||
const response = await fetch( |
|
||||||
(process.env.REACT_APP_BACKEND || "") + `/integrations/${integrationId}/v1/search?q=queens&type=artist`, |
|
||||||
requestOpts |
|
||||||
); |
|
||||||
if (!response.ok) { |
|
||||||
throw new Error("Response to tag merge not OK: " + JSON.stringify(response)); |
|
||||||
} |
|
||||||
console.log("Spotify response: ", response); |
|
||||||
} |
|
@ -0,0 +1,161 @@ |
|||||||
|
import React, { useState, useContext, createContext, useReducer, useEffect } from "react"; |
||||||
|
import Integration from "./Integration"; |
||||||
|
import * as serverApi from '../../api'; |
||||||
|
import SpotifyClientCreds from "./spotify/SpotifyClientCreds"; |
||||||
|
import * as backend from "../backend/integrations"; |
||||||
|
import { handleNotLoggedIn, NotLoggedInError } from "../backend/request"; |
||||||
|
import { useAuth } from "../useAuth"; |
||||||
|
|
||||||
|
export type IntegrationState = { |
||||||
|
id: number, |
||||||
|
integration: Integration, |
||||||
|
properties: serverApi.CreateIntegrationRequest, |
||||||
|
}; |
||||||
|
export type IntegrationsState = IntegrationState[] | "Loading"; |
||||||
|
|
||||||
|
export function isIntegrationState(v: any) : v is IntegrationState { |
||||||
|
return 'id' in v && 'integration' in v && 'properties' in v; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Integrations { |
||||||
|
state: IntegrationsState, |
||||||
|
addIntegration: (v: serverApi.CreateIntegrationRequest) => Promise<number>, |
||||||
|
deleteIntegration: (id: number) => Promise<void>, |
||||||
|
modifyIntegration: (id: number, v: serverApi.CreateIntegrationRequest) => Promise<void>, |
||||||
|
updateFromUpstream: () => Promise<void>, |
||||||
|
}; |
||||||
|
|
||||||
|
export const IntegrationClasses: Record<any, any> = { |
||||||
|
[serverApi.IntegrationType.SpotifyClientCredentials]: SpotifyClientCreds, |
||||||
|
} |
||||||
|
|
||||||
|
export function makeDefaultIntegrationProperties(type: serverApi.IntegrationType):
|
||||||
|
serverApi.CreateIntegrationRequest { |
||||||
|
switch(type) { |
||||||
|
case serverApi.IntegrationType.SpotifyClientCredentials: { |
||||||
|
return { |
||||||
|
name: "Spotify", |
||||||
|
type: type, |
||||||
|
details: { clientId: "" }, |
||||||
|
secretDetails: { clientSecret: "" }, |
||||||
|
} |
||||||
|
} |
||||||
|
default: { |
||||||
|
throw new Error("Unimplemented default integration.") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function makeIntegration(p: serverApi.CreateIntegrationRequest, id: number) { |
||||||
|
switch(p.type) { |
||||||
|
case serverApi.IntegrationType.SpotifyClientCredentials: { |
||||||
|
return new SpotifyClientCreds(id); |
||||||
|
} |
||||||
|
default: { |
||||||
|
throw new Error("Unimplemented integration type.") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const integrationsContext = createContext<Integrations>({ |
||||||
|
state: [], |
||||||
|
addIntegration: async () => 0, |
||||||
|
modifyIntegration: async () => { }, |
||||||
|
deleteIntegration: async () => { }, |
||||||
|
updateFromUpstream: async () => { }, |
||||||
|
}); |
||||||
|
|
||||||
|
export function ProvideIntegrations(props: { children: any }) { |
||||||
|
const integrations = useProvideIntegrations(); |
||||||
|
return <integrationsContext.Provider value={integrations}>{props.children}</integrationsContext.Provider>; |
||||||
|
} |
||||||
|
|
||||||
|
export const useIntegrations = () => { |
||||||
|
return useContext(integrationsContext); |
||||||
|
}; |
||||||
|
|
||||||
|
function useProvideIntegrations(): Integrations { |
||||||
|
let auth = useAuth(); |
||||||
|
enum IntegrationsActions { |
||||||
|
SetItem = "SetItem", |
||||||
|
Set = "Set", |
||||||
|
DeleteItem = "DeleteItem", |
||||||
|
AddItem = "AddItem", |
||||||
|
} |
||||||
|
let IntegrationsReducer = (state: IntegrationsState, action: any): IntegrationsState => { |
||||||
|
switch (action.type) { |
||||||
|
case IntegrationsActions.SetItem: { |
||||||
|
if (state !== "Loading") { |
||||||
|
return state.map((item: any) => { |
||||||
|
return (item.id === action.id) ? action.value : item; |
||||||
|
}) |
||||||
|
} |
||||||
|
return state; |
||||||
|
} |
||||||
|
case IntegrationsActions.Set: { |
||||||
|
return action.value; |
||||||
|
} |
||||||
|
case IntegrationsActions.DeleteItem: { |
||||||
|
if (state !== "Loading") { |
||||||
|
const newState = [...state]; |
||||||
|
return newState.filter((item: any) => item.id !== action.id); |
||||||
|
} |
||||||
|
return state; |
||||||
|
} |
||||||
|
case IntegrationsActions.AddItem: { |
||||||
|
return [...state, action.value]; |
||||||
|
} |
||||||
|
default: |
||||||
|
throw new Error("Unimplemented Integrations state update.") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const [state, dispatch] = useReducer(IntegrationsReducer, []) |
||||||
|
|
||||||
|
let updateFromUpstream = async () => { |
||||||
|
backend.getIntegrations() |
||||||
|
.then((integrations: serverApi.ListIntegrationsResponse) => { |
||||||
|
dispatch({ |
||||||
|
type: IntegrationsActions.Set, |
||||||
|
value: integrations.map((i: any) => { |
||||||
|
return { |
||||||
|
integration: new (IntegrationClasses[i.type])(i.id), |
||||||
|
properties: { ...i }, |
||||||
|
id: i.id, |
||||||
|
} |
||||||
|
}) |
||||||
|
}); |
||||||
|
}) |
||||||
|
.catch((e: NotLoggedInError) => handleNotLoggedIn(auth, e)); |
||||||
|
} |
||||||
|
|
||||||
|
let addIntegration = async (v: serverApi.CreateIntegrationRequest) => { |
||||||
|
const id = await backend.createIntegration(v); |
||||||
|
await updateFromUpstream(); |
||||||
|
return id; |
||||||
|
} |
||||||
|
|
||||||
|
let deleteIntegration = async (id: number) => { |
||||||
|
await backend.deleteIntegration(id); |
||||||
|
await updateFromUpstream(); |
||||||
|
} |
||||||
|
|
||||||
|
let modifyIntegration = async (id: number, v: serverApi.CreateIntegrationRequest) => { |
||||||
|
await backend.modifyIntegration(id, v); |
||||||
|
await updateFromUpstream(); |
||||||
|
} |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (auth.user) { |
||||||
|
updateFromUpstream() |
||||||
|
} |
||||||
|
}, [auth]); |
||||||
|
|
||||||
|
return { |
||||||
|
state: state, |
||||||
|
addIntegration: addIntegration, |
||||||
|
modifyIntegration: modifyIntegration, |
||||||
|
deleteIntegration: deleteIntegration, |
||||||
|
updateFromUpstream: updateFromUpstream, |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue