Global integrations context which now works together with the edit settings window.

pull/34/head
Sander Vocke 5 years ago
parent f369d4e390
commit d8438ec3c1
  1. 8
      client/package-lock.json
  2. 1
      client/package.json
  3. 3
      client/src/App.tsx
  4. 1
      client/src/api.ts
  5. 14
      client/src/components/MainWindow.tsx
  6. 11
      client/src/components/windows/album/AlbumWindow.tsx
  7. 8
      client/src/components/windows/artist/ArtistWindow.tsx
  8. 7
      client/src/components/windows/manage_tags/ManageTagsWindow.tsx
  9. 330
      client/src/components/windows/settings/IntegrationSettings.tsx
  10. 352
      client/src/components/windows/settings/IntegrationSettingsEditor.tsx
  11. 2
      client/src/components/windows/settings/SettingsWindow.tsx
  12. 12
      client/src/lib/backend/integrations.tsx
  13. 9
      client/src/lib/backend/queries.tsx
  14. 24
      client/src/lib/backend/request.tsx
  15. 9
      client/src/lib/backend/tags.tsx
  16. 59
      client/src/lib/integration/Integration.tsx
  17. 95
      client/src/lib/integration/spotify/SpotifyClientCreds.tsx
  18. 14
      client/src/lib/integration/spotify/spotifyClientCreds.tsx
  19. 161
      client/src/lib/integration/useIntegrations.tsx
  20. 9
      client/src/lib/saveChanges.tsx
  21. 3
      server/endpoints/Integration.ts
  22. 5
      server/integrations/integrations.ts

@ -11286,6 +11286,14 @@
"resolved": "https://registry.npmjs.org/react-double-scrollbar/-/react-double-scrollbar-0.0.15.tgz",
"integrity": "sha1-6RWrjLO5WYdwdfSUNt6/2wQoj+Q="
},
"react-error-boundary": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.0.2.tgz",
"integrity": "sha512-KVzCusRTFpUYG0OFJbzbdRuxNQOBiGXVCqyNpBXM9z5NFsFLzMjUXMjx8gTja6M6WH+D2PvP3yKz4d8gD1PRaA==",
"requires": {
"@babel/runtime": "^7.11.2"
}
},
"react-error-overlay": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.7.tgz",

@ -24,6 +24,7 @@
"react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^16.13.1",
"react-error-boundary": "^3.0.2",
"react-router-dom": "^5.2.0",
"react-scripts": "^3.4.3",
"typescript": "~3.7.2",

@ -5,13 +5,12 @@ import { HTML5Backend } from 'react-dnd-html5-backend';
import MainWindow from './components/MainWindow';
import { ProvideAuth } from './lib/useAuth';
import { ProvideIntegrations } from './lib/integration/useIntegrations';
function App() {
return (
<DndProvider backend={HTML5Backend}>
<ProvideAuth>
<MainWindow />
</ProvideAuth>
</DndProvider>
);
}

@ -364,7 +364,6 @@ export enum IntegrationType {
export interface SpotifyClientCredentialsDetails {
clientId: string,
clientSecret: string,
}
export interface SpotifyClientCredentialsSecretDetails {

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core';
import { grey } from '@material-ui/core/colors';
import AppBar, { AppBarTab } from './appbar/AppBar';
@ -8,11 +8,13 @@ import AlbumWindow from './windows/album/AlbumWindow';
import TagWindow from './windows/tag/TagWindow';
import SongWindow from './windows/song/SongWindow';
import ManageTagsWindow from './windows/manage_tags/ManageTagsWindow';
import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom';
import { BrowserRouter, Switch, Route, Redirect, useHistory } from 'react-router-dom';
import LoginWindow from './windows/login/LoginWindow';
import { useAuth } from '../lib/useAuth';
import { useAuth, ProvideAuth } from '../lib/useAuth';
import RegisterWindow from './windows/register/RegisterWindow';
import SettingsWindow from './windows/settings/SettingsWindow';
import { ErrorBoundary } from 'react-error-boundary';
import { ProvideIntegrations } from '../lib/integration/useIntegrations';
const darkTheme = createMuiTheme({
palette: {
@ -45,6 +47,8 @@ function PrivateRoute(props: any) {
export default function MainWindow(props: any) {
return <ThemeProvider theme={darkTheme}>
<CssBaseline />
<ProvideAuth>
<ProvideIntegrations>
<BrowserRouter>
<Switch>
<Route exact path="/">
@ -88,5 +92,7 @@ export default function MainWindow(props: any) {
</PrivateRoute>
</Switch>
</BrowserRouter>
</ThemeProvider>
</ProvideIntegrations>
</ProvideAuth>
</ThemeProvider >
}

@ -12,6 +12,8 @@ import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryAlbums, querySongs } from '../../../lib/backend/queries';
import { songGetters } from '../../../lib/songGetters';
import { useParams } from 'react-router';
import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth';
export type AlbumMetadata = serverApi.AlbumDetails;
export type AlbumMetadataChanges = serverApi.ModifyAlbumRequest;
@ -55,7 +57,8 @@ export async function getAlbumMetadata(id: number) {
},
offset: 0,
limit: 1,
}))[0];
})
)[0];
}
export default function AlbumWindow(props: {}) {
@ -77,6 +80,7 @@ export function AlbumWindowControlled(props: {
}) {
let { id: albumId, metadata, pendingChanges, songsOnAlbum } = props.state;
let { dispatch } = props;
let auth = useAuth();
// Effect to get the album's metadata.
useEffect(() => {
@ -87,6 +91,7 @@ export function AlbumWindowControlled(props: {
value: m
});
})
.catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
}, [albumId, dispatch]);
// Effect to get the album's songs.
@ -102,7 +107,8 @@ export function AlbumWindowControlled(props: {
},
offset: 0,
limit: -1,
});
})
.catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) });
dispatch({
type: AlbumWindowStateActions.SetSongs,
value: songs,
@ -153,6 +159,7 @@ export function AlbumWindowControlled(props: {
type: AlbumWindowStateActions.Reload
})
})
.catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
}} />
{applying && <CircularProgress />}
</Box>

@ -12,6 +12,8 @@ import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryArtists, querySongs } from '../../../lib/backend/queries';
import { songGetters } from '../../../lib/songGetters';
import { useParams } from 'react-router';
import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth';
export type ArtistMetadata = serverApi.ArtistDetails;
export type ArtistMetadataChanges = serverApi.ModifyArtistRequest;
@ -82,6 +84,7 @@ export function ArtistWindowControlled(props: {
}) {
let { metadata, id: artistId, pendingChanges, songsByArtist } = props.state;
let { dispatch } = props;
let auth = useAuth();
// Effect to get the artist's metadata.
useEffect(() => {
@ -92,6 +95,7 @@ export function ArtistWindowControlled(props: {
value: m
});
})
.catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
}, [artistId, dispatch]);
// Effect to get the artist's songs.
@ -107,7 +111,8 @@ export function ArtistWindowControlled(props: {
},
offset: 0,
limit: -1,
});
})
.catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) });
dispatch({
type: ArtistWindowStateActions.SetSongs,
value: songs,
@ -158,6 +163,7 @@ export function ArtistWindowControlled(props: {
type: ArtistWindowStateActions.Reload
})
})
.catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
}} />
{applying && <CircularProgress />}
</Box>

@ -11,6 +11,8 @@ import NewTagMenu from './NewTagMenu';
import { v4 as genUuid } from 'uuid';
import Alert from '@material-ui/lab/Alert';
import { useHistory } from 'react-router';
import { NotLoggedInError, handleNotLoggedIn } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth';
var _ = require('lodash');
export interface ManageTagsWindowState extends WindowState {
@ -355,6 +357,7 @@ export function ManageTagsWindowControlled(props: {
const [newTagMenuPos, setNewTagMenuPos] = React.useState<null | number[]>(null);
let { fetchedTags } = props.state;
let { dispatch } = props;
let auth = useAuth();
const onOpenNewTagMenu = (e: any) => {
setNewTagMenuPos([e.clientX, e.clientY])
@ -422,7 +425,9 @@ export function ManageTagsWindowControlled(props: {
props.dispatch({
type: ManageTagsWindowActions.Reset
});
}).catch((e: Error) => {
})
.catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
.catch((e: Error) => {
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: <Alert severity="error">Failed to save changes: {e.message}</Alert>,

@ -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);
}}
/>
</>;
}

@ -5,7 +5,7 @@ import { useHistory } from 'react-router';
import { useAuth, Auth } from '../../../lib/useAuth';
import Alert from '@material-ui/lab/Alert';
import { Link } from 'react-router-dom';
import IntegrationSettingsEditor from './IntegrationSettingsEditor';
import IntegrationSettingsEditor from './IntegrationSettings';
export enum SettingsTab {
Integrations = 0,

@ -1,4 +1,6 @@
import * as serverApi from '../../api';
import { useAuth } from '../useAuth';
import backendRequest from './request';
export async function createIntegration(details: serverApi.CreateIntegrationRequest) {
const requestOpts = {
@ -7,10 +9,11 @@ export async function createIntegration(details: serverApi.CreateIntegrationRequ
body: JSON.stringify(details),
};
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.CreateIntegrationEndpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.CreateIntegrationEndpoint, requestOpts)
if (!response.ok) {
throw new Error("Response to integration creation not OK: " + JSON.stringify(response));
}
return await response.json();
}
@ -21,10 +24,11 @@ export async function modifyIntegration(id: number, details: serverApi.ModifyInt
body: JSON.stringify(details),
};
const response = await fetch(
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.ModifyIntegrationEndpoint.replace(':id', id.toString()),
requestOpts
);
if (!response.ok) {
throw new Error("Response to integration modification not OK: " + JSON.stringify(response));
}
@ -35,7 +39,7 @@ export async function deleteIntegration(id: number) {
method: 'DELETE',
};
const response = await fetch(
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.DeleteIntegrationEndpoint.replace(':id', id.toString()),
requestOpts
);
@ -49,7 +53,7 @@ export async function getIntegrations() {
method: 'GET',
};
const response = await fetch(
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.ListIntegrationsEndpoint,
requestOpts
);

@ -1,5 +1,6 @@
import * as serverApi from '../../api';
import { QueryElem, toApiQuery } from '../query/Query';
import backendRequest from './request';
export interface QueryArgs {
query?: QueryElem,
@ -29,7 +30,7 @@ export async function queryArtists(args: QueryArgs) {
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
return json.artists;
})();
@ -57,7 +58,7 @@ export async function queryAlbums(args: QueryArgs) {
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
return json.albums;
})();
@ -85,7 +86,7 @@ export async function querySongs(args: QueryArgs) {
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
return json.songs;
})();
@ -113,7 +114,7 @@ export async function queryTags(args: QueryArgs) {
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts);
let json: any = await response.json();
const tags = json.tags;

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

@ -1,4 +1,5 @@
import * as serverApi from '../../api';
import backendRequest from './request';
export async function createTag(details: serverApi.CreateTagRequest) {
const requestOpts = {
@ -7,7 +8,7 @@ export async function createTag(details: serverApi.CreateTagRequest) {
body: JSON.stringify(details),
};
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.CreateTagEndpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.CreateTagEndpoint, requestOpts)
if (!response.ok) {
throw new Error("Response to tag creation not OK: " + JSON.stringify(response));
}
@ -21,7 +22,7 @@ export async function modifyTag(id: number, details: serverApi.ModifyTagRequest)
body: JSON.stringify(details),
};
const response = await fetch(
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.ModifyTagEndpoint.replace(':id', id.toString()),
requestOpts
);
@ -35,7 +36,7 @@ export async function deleteTag(id: number) {
method: 'DELETE',
};
const response = await fetch(
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.DeleteTagEndpoint.replace(':id', id.toString()),
requestOpts
);
@ -49,7 +50,7 @@ export async function mergeTag(fromId: number, toId: number) {
method: 'POST',
};
const response = await fetch(
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.MergeTagEndpoint
.replace(':id', fromId.toString())
.replace(':toId', toId.toString()),

@ -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,
}
}

@ -1,4 +1,5 @@
import * as serverApi from '../api';
import backendRequest from './backend/request';
export async function saveSongChanges(id: number, change: serverApi.ModifySongRequest) {
const requestOpts = {
@ -8,7 +9,7 @@ export async function saveSongChanges(id: number, change: serverApi.ModifySongRe
};
const endpoint = serverApi.ModifySongEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save song changes: " + response.statusText);
}
@ -22,7 +23,7 @@ export async function saveTagChanges(id: number, change: serverApi.ModifyTagRequ
};
const endpoint = serverApi.ModifyTagEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save tag changes: " + response.statusText);
}
@ -36,7 +37,7 @@ export async function saveArtistChanges(id: number, change: serverApi.ModifyArti
};
const endpoint = serverApi.ModifyArtistEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save artist changes: " + response.statusText);
}
@ -50,7 +51,7 @@ export async function saveAlbumChanges(id: number, change: serverApi.ModifyAlbum
};
const endpoint = serverApi.ModifyAlbumEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save album changes: " + response.statusText);
}

@ -86,6 +86,8 @@ export const ListIntegrations: EndpointHandler = async (req: any, res: any, knex
const { id: userId } = req.user;
console.log("List integrations");
try {
const integrations: api.ListIntegrationsResponse = (
await knex.select(['id', 'name', 'type', 'details'])
@ -100,6 +102,7 @@ export const ListIntegrations: EndpointHandler = async (req: any, res: any, knex
}
})
console.log("Found integrations:", integrations);
await res.send(integrations);
} catch (e) {
catchUnhandledErrors(e)

@ -57,7 +57,7 @@ export function createIntegrations(knex: Knex) {
res.status(400).send({ reason: "An integration ID should be provided in the URL." });
return;
}
req._integration = (await knex.select(['id', 'name', 'type', 'details'])
req._integration = (await knex.select(['id', 'name', 'type', 'details', 'secretDetails'])
.from('integrations')
.where({ 'user': req.user.id, 'id': req._integrationId }))[0];
if (!req._integration) {
@ -66,6 +66,7 @@ export function createIntegrations(knex: Knex) {
}
req._integration.details = JSON.parse(req._integration.details);
req._integration.secretDetails = JSON.parse(req._integration.secretDetails);
switch (req._integration.type) {
case IntegrationType.SpotifyClientCredentials: {
@ -73,7 +74,7 @@ export function createIntegrations(knex: Knex) {
// FIXME: persist the token
req._access_token = await getSpotifyCCAuthToken(
req._integration.details.clientId,
req._integration.details.clientSecret,
req._integration.secretDetails.clientSecret,
)
if (!req._access_token) {
res.status(500).send({ reason: "Unable to get Spotify auth token." })

Loading…
Cancel
Save