feat: add app connections w/ testing and deleting functions

This commit is contained in:
Ali BARIN
2021-10-18 23:58:40 +02:00
parent 672cc4c60c
commit 2293c939e7
27 changed files with 349 additions and 34 deletions

View File

@@ -1,6 +1,7 @@
import TwitterApi from 'twitter-api-v2'; import TwitterApi from 'twitter-api-v2';
import App from '../../models/app'; import App from '../../models/app';
import Field from '../../types/field'; import Field from '../../types/field';
import appData from './info';
export default class Twitter { export default class Twitter {
client: any client: any
@@ -16,7 +17,7 @@ export default class Twitter {
}); });
this.connectionData = connectionData; this.connectionData = connectionData;
this.appData = App.findOneByKey('twitter'); this.appData = appData;
} }
async createAuthLink() { async createAuthLink() {

View File

@@ -1,5 +1,6 @@
import { GraphQLString, GraphQLNonNull } from 'graphql'; import { GraphQLString, GraphQLNonNull } from 'graphql';
import Connection from '../../models/connection'; import Connection from '../../models/connection';
import App from '../../models/app';
import connectionType from '../types/connection'; import connectionType from '../types/connection';
import twitterCredentialInputType from '../types/twitter-credential-input'; import twitterCredentialInputType from '../types/twitter-credential-input';
import RequestWithCurrentUser from '../../types/express/request-with-current-user'; import RequestWithCurrentUser from '../../types/express/request-with-current-user';
@@ -9,13 +10,17 @@ type Params = {
data: object data: object
} }
const createConnectionResolver = async (params: Params, req: RequestWithCurrentUser) => { const createConnectionResolver = async (params: Params, req: RequestWithCurrentUser) => {
const app = await App.findOneByKey(params.key);
const connection = await Connection.query().insert({ const connection = await Connection.query().insert({
key: params.key, key: params.key,
data: params.data, data: params.data,
userId: req.currentUser.id userId: req.currentUser.id
}); });
return connection; return {
...connection,
app,
};
} }
const createConnection = { const createConnection = {

View File

@@ -1,5 +1,6 @@
import { GraphQLList, GraphQLString, GraphQLNonNull } from 'graphql'; import { GraphQLList, GraphQLString, GraphQLNonNull } from 'graphql';
import Connection from '../../models/connection'; import Connection from '../../models/connection';
import App from '../../models/app';
import RequestWithCurrentUser from '../../types/express/request-with-current-user'; import RequestWithCurrentUser from '../../types/express/request-with-current-user';
import connectionType from '../types/connection'; import connectionType from '../types/connection';
@@ -8,10 +9,14 @@ type Params = {
} }
const getAppConnectionsResolver = async (params: Params, req: RequestWithCurrentUser) => { const getAppConnectionsResolver = async (params: Params, req: RequestWithCurrentUser) => {
const app = await App.findOneByKey(params.key);
const connections = await Connection.query() const connections = await Connection.query()
.where({ user_id: req.currentUser.id, verified: true, key: params.key }) .where({ user_id: req.currentUser.id, key: params.key })
return connections; return connections.map((connection: any) => ({
...connection,
app,
}));
} }
const getAppConnections = { const getAppConnections = {

View File

@@ -1,17 +1,32 @@
import { GraphQLString, GraphQLNonNull } from 'graphql'; import { GraphQLString, GraphQLNonNull } from 'graphql';
import App from '../../models/app'; import App from '../../models/app';
import appType from '../types/app'; import appType from '../types/app';
import RequestWithCurrentUser from '../../types/express/request-with-current-user';
import Connection from '../../models/connection';
import connectionType from '../types/connection';
type Params = { type Params = {
key: string key: string
} }
const getAppResolver = (params: Params) => { const getAppResolver = async (params: Params, req: RequestWithCurrentUser) => {
if(!params.key) { if(!params.key) {
throw new Error('No key provided.') throw new Error('No key provided.')
} }
return App.findOneByKey(params.key) const app = await App.findOneByKey(params.key);
if (req.currentUser?.id) {
const connections = await Connection.query()
.where({ user_id: req.currentUser.id, key: params.key });
return {
...app,
connections,
};
}
return app;
} }
const getApp = { const getApp = {
@@ -19,9 +34,7 @@ const getApp = {
args: { args: {
key: { type: GraphQLNonNull(GraphQLString) }, key: { type: GraphQLNonNull(GraphQLString) },
}, },
resolve: (_: any, params: Params) => getAppResolver(params) resolve: (_: any, params: Params, req: RequestWithCurrentUser) => getAppResolver(params, req)
} }
export default getApp; export default getApp;

View File

@@ -15,7 +15,7 @@ const testConnectionResolver = async (params: Params, req: RequestWithCurrentUse
const appClass = (await import(`../../apps/${connection.key}`)).default; const appClass = (await import(`../../apps/${connection.key}`)).default;
const appInstance = new appClass(connection.data) const appInstance = new appClass(connection.data);
const isStillVerified = await appInstance.isStillVerified(); const isStillVerified = await appInstance.isStillVerified();
connection = await connection.$query().patchAndFetch({ connection = await connection.$query().patchAndFetch({

View File

@@ -4,15 +4,20 @@ import authenticationStepType from './authentication-step';
const appType = new GraphQLObjectType({ const appType = new GraphQLObjectType({
name: 'App', name: 'App',
fields: { fields: () => {
name: { type: GraphQLString }, const connectionType = require('./connection').default;
key: { type: GraphQLString },
connectionCount: { type: GraphQLInt }, return {
iconUrl: { type: GraphQLString }, name: { type: GraphQLString },
docUrl: { type: GraphQLString }, key: { type: GraphQLString },
primaryColor: { type: GraphQLString }, connectionCount: { type: GraphQLInt },
fields: { type: GraphQLList(fieldType) }, iconUrl: { type: GraphQLString },
authenticationSteps: { type: GraphQLList(authenticationStepType) } docUrl: { type: GraphQLString },
primaryColor: { type: GraphQLString },
fields: { type: GraphQLList(fieldType) },
authenticationSteps: { type: GraphQLList(authenticationStepType) },
connections: { type: GraphQLList(connectionType) },
}
} }
}); });

View File

@@ -3,11 +3,16 @@ import connectionDataType from './connection-data';
const connectionType = new GraphQLObjectType({ const connectionType = new GraphQLObjectType({
name: 'connection', name: 'connection',
fields: { fields: () => {
id: { type: GraphQLString }, const appType = require('./app').default;
key: { type: GraphQLString },
data: { type: connectionDataType }, return {
verified: { type: GraphQLBoolean }, id: { type: GraphQLString },
key: { type: GraphQLString },
data: { type: connectionDataType },
verified: { type: GraphQLBoolean },
app: { type: appType }
}
} }
}) })

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@apollo/client'; import { useQuery } from '@apollo/client';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';

View File

@@ -0,0 +1,57 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import Menu from '@mui/material/Menu';
import type { PopoverProps } from '@mui/material/Popover';
import MenuItem from '@mui/material/MenuItem';
import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage';
type Action = {
type: 'test' | 'reconnect' | 'delete' | 'viewFlows';
};
type ContextMenuProps = {
appKey: string;
onClose: () => void;
onMenuItemClick: (event: React.MouseEvent, action: Action) => void;
anchorEl: PopoverProps['anchorEl'];
};
export default function ContextMenu(props: ContextMenuProps) {
const { appKey, onClose, onMenuItemClick, anchorEl } = props;
const formatMessage = useFormatMessage();
const createActionHandler = React.useCallback((action: Action) => {
return function clickHandler(event: React.MouseEvent) {
onMenuItemClick(event, action);
onClose();
};
}, [onMenuItemClick, onClose]);
return (
<Menu
open={true}
onClose={onClose}
hideBackdrop={false}
anchorEl={anchorEl}
>
<MenuItem component={Link} to={URLS.APP_FLOWS(appKey)} onClick={createActionHandler({ type: 'viewFlows' })}>
{formatMessage('connection.viewFlows')}
</MenuItem>
<MenuItem onClick={createActionHandler({ type: 'test' })}>
{formatMessage('connection.testConnection')}
</MenuItem>
<MenuItem onClick={createActionHandler({ type: 'reconnect' })}>
{formatMessage('connection.reconnect')}
</MenuItem>
<MenuItem onClick={createActionHandler({ type: 'delete' })}>
{formatMessage('connection.delete')}
</MenuItem>
</Menu>
);
};

View File

@@ -0,0 +1,94 @@
import * as React from 'react';
import { useLazyQuery, useMutation } from '@apollo/client';
import Card from '@mui/material/Card';
import Box from '@mui/material/Box';
import CardActionArea from '@mui/material/CardActionArea';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import { DELETE_CONNECTION } from 'graphql/mutations/delete-connection';
import { TEST_CONNECTION } from 'graphql/queries/test-connection';
import ConnectionContextMenu from 'components/AppConnectionContextMenu';
import useFormatMessage from 'hooks/useFormatMessage';
import type { Connection } from 'types/connection';
import { CardContent, Typography } from './style';
type AppConnectionRowProps = {
connection: Connection;
}
const countTranslation = (value: React.ReactNode) => (<><strong>{value}</strong><br /></>);
function AppConnectionRow(props: AppConnectionRowProps) {
const [testConnection, { data: testData, called: testCalled, loading: testLoading }] = useLazyQuery(TEST_CONNECTION);
const [deleteConnection] = useMutation(DELETE_CONNECTION);
const formatMessage = useFormatMessage();
const { id, key, data } = props.connection;
const contextButtonRef = React.useRef<SVGSVGElement | null>(null);
const [anchorEl, setAnchorEl] = React.useState<SVGSVGElement | null>(null);
const handleClose = () => {
setAnchorEl(null);
};
const onContextMenuClick = (event: React.MouseEvent<HTMLButtonElement>) => setAnchorEl(contextButtonRef.current);
const onContextMenuAction = React.useCallback((event, action: { [key: string]: string }) => {
if (action.type === 'delete') {
deleteConnection({
variables: { id },
update: (cache, mutationResult) => {
const connectionCacheId = cache.identify({
__typename: 'connection',
id,
});
cache.evict({
id: connectionCacheId,
});
}
});
} else if (action.type === 'test') {
testConnection({ variables: { id } });
}
}, [deleteConnection, id, testConnection]);
return (
<>
<Card sx={{ my: 2 }}>
<CardActionArea onClick={onContextMenuClick}>
<CardContent>
<Box>
<Typography variant="h6">
{data.screenName}
</Typography>
</Box>
<Box>
{testCalled && !testLoading && (testData ? 'yes' : 'no')}
</Box>
<Box sx={{ px: 2 }}>
<Typography variant="body2">
{formatMessage('connection.flowCount', { count: countTranslation(0) })}
</Typography>
</Box>
<Box>
<MoreHorizIcon ref={contextButtonRef} />
</Box>
</CardContent>
</CardActionArea>
</Card>
{anchorEl && <ConnectionContextMenu
appKey={key}
onClose={handleClose}
onMenuItemClick={onContextMenuAction}
anchorEl={anchorEl}
/>}
</>
);
}
export default AppConnectionRow;

View File

@@ -0,0 +1,17 @@
import { styled } from '@mui/material/styles';
import MuiCardContent from '@mui/material/CardContent';
import MuiTypography from '@mui/material/Typography';
export const CardContent = styled(MuiCardContent)(({ theme }) => ({
display: 'grid',
gridTemplateRows: 'auto',
gridTemplateColumns: '1fr auto auto auto',
gridColumnGap: theme.spacing(2),
alignItems: 'center',
}));
export const Typography = styled(MuiTypography)(({ theme }) => ({
textAlign: 'center',
display: 'inline-block',
}));

View File

@@ -0,0 +1,23 @@
import { useQuery } from '@apollo/client';
import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections';
import AppConnectionRow from 'components/AppConnectionRow';
import type { Connection } from 'types/connection';
type AppConnectionsProps = {
appKey: String;
}
export default function AppConnections(props: AppConnectionsProps) {
const { appKey: key } = props;
const { data } = useQuery(GET_APP_CONNECTIONS, { variables: { key } });
const appConnections: Connection[] = data?.getApp?.connections || [];
return (
<>
{appConnections.map((appConnection: Connection) => (
<AppConnectionRow key={appConnection.id} connection={appConnection} />
))}
</>
)
};

View File

@@ -24,7 +24,7 @@ function AppRow(props: AppRowProps) {
<Link to={URLS.APP(name.toLowerCase())}> <Link to={URLS.APP(name.toLowerCase())}>
<Card sx={{ my: 2 }}> <Card sx={{ my: 2 }}>
<CardActionArea> <CardActionArea>
<CardContent> <CardContent>
<Box> <Box>
<AppIcon name={name} url={iconUrl} color={primaryColor} /> <AppIcon name={name} url={iconUrl} color={primaryColor} />
</Box> </Box>

View File

@@ -19,12 +19,10 @@ export default function InputCreator(props: InputCreatorProps) {
const { const {
key: name, key: name,
label, label,
type,
required, required,
readOnly, readOnly,
value, value,
description, description,
docUrl,
clickToCopy, clickToCopy,
} = schema; } = schema;

View File

@@ -9,7 +9,7 @@ type LayoutProps = {
} }
export default function Layout({ children }: LayoutProps) { export default function Layout({ children }: LayoutProps) {
const [isDrawerOpen, setDrawerOpen] = useState(false); const [isDrawerOpen, setDrawerOpen] = useState(true);
const onMenuClick = useCallback(() => { setDrawerOpen(value => !value) }, []); const onMenuClick = useCallback(() => { setDrawerOpen(value => !value) }, []);
return ( return (

View File

@@ -4,6 +4,31 @@ const cache = new InMemoryCache({
typePolicies: { typePolicies: {
App: { App: {
keyFields: ['key'] keyFields: ['key']
},
Mutation: {
mutationType: true,
fields: {
createConnection: {
merge(existing, newConnection, { args, readField, cache }) {
const appKey = readField('key', newConnection);
const appCacheId = cache.identify({
__typename: 'App',
key: appKey,
});
cache.modify({
id: appCacheId,
fields: {
connections: (existingConnections) => {
return [...existingConnections, newConnection];
}
}
});
return newConnection;
}
}
}
} }
} }
}); });

View File

@@ -3,8 +3,15 @@ import { gql } from '@apollo/client';
export const CREATE_CONNECTION = gql` export const CREATE_CONNECTION = gql`
mutation CreateConnection($key: String!, $data: twitterCredentialInput!) { mutation CreateConnection($key: String!, $data: twitterCredentialInput!) {
createConnection(key: $key, data: $data) { createConnection(key: $key, data: $data) {
key
id id
key
verified
data {
screenName
}
app {
key
}
} }
} }
`; `;

View File

@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_CONNECTION = gql`
mutation DeleteConnection($id: String!) {
deleteConnection(id: $id)
}
`;

View File

@@ -1,6 +1,7 @@
import { CREATE_CONNECTION } from './create-connection'; import { CREATE_CONNECTION } from './create-connection';
import { CREATE_AUTH_LINK } from './create-auth-link';
import { UPDATE_CONNECTION } from './update-connection'; import { UPDATE_CONNECTION } from './update-connection';
import { DELETE_CONNECTION } from './delete-connection';
import { CREATE_AUTH_LINK } from './create-auth-link';
type Mutations = { type Mutations = {
[key: string]: any, [key: string]: any,
@@ -9,6 +10,7 @@ type Mutations = {
const mutations: Mutations = { const mutations: Mutations = {
createConnection: CREATE_CONNECTION, createConnection: CREATE_CONNECTION,
updateConnection: UPDATE_CONNECTION, updateConnection: UPDATE_CONNECTION,
deleteConnection: DELETE_CONNECTION,
createAuthLink: CREATE_AUTH_LINK, createAuthLink: CREATE_AUTH_LINK,
}; };

View File

@@ -6,6 +6,9 @@ export const UPDATE_CONNECTION = gql`
id id
key key
verified verified
data {
screenName
}
} }
} }
`; `;

View File

@@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const GET_APP_CONNECTIONS = gql`
query GetAppConnections($key: String!) {
getApp(key: $key) {
key
connections {
id
key
verified
data {
screenName
}
}
}
}
`;

View File

@@ -32,6 +32,9 @@ export const GET_APP = gql`
} }
} }
} }
connections {
id
}
} }
} }
`; `;

View File

@@ -19,6 +19,9 @@ export const GET_APPS = gql`
docUrl docUrl
clickToCopy clickToCopy
} }
connections {
id
}
} }
} }
`; `;

View File

@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const TEST_CONNECTION = gql`
query TestConnection($id: String!) {
testConnection(id: $id)
}
`;

View File

@@ -15,5 +15,10 @@
"apps.title": "Apps", "apps.title": "Apps",
"apps.addConnection": "Add connection", "apps.addConnection": "Add connection",
"apps.addNewAppConnection": "Add a new app connection", "apps.addNewAppConnection": "Add a new app connection",
"apps.searchApp": "Search for app" "apps.searchApp": "Search for app",
"connection.flowCount": "{count} flows",
"connection.viewFlows": "View flows",
"connection.testConnection": "Test connection",
"connection.reconnect": "Reconnect",
"connection.delete": "Delete"
} }

View File

@@ -12,6 +12,7 @@ import useFormatMessage from 'hooks/useFormatMessage';
import { GET_APP } from 'graphql/queries/get-app'; import { GET_APP } from 'graphql/queries/get-app';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import AppConnections from 'components/AppConnections';
import AddAppConnection from 'components/AddAppConnection'; import AddAppConnection from 'components/AddAppConnection';
import AppIcon from 'components/AppIcon'; import AppIcon from 'components/AppIcon';
import Container from 'components/Container'; import Container from 'components/Container';
@@ -78,11 +79,11 @@ export default function Application() {
<Switch> <Switch>
<Route path={URLS.APP_FLOWS_PATTERN}> <Route path={URLS.APP_FLOWS_PATTERN}>
Flows come here. Flows
</Route> </Route>
<Route path={URLS.APP_CONNECTIONS_PATTERN}> <Route path={URLS.APP_CONNECTIONS_PATTERN}>
Connections come here. <AppConnections appKey={key} />
</Route> </Route>
<Route exact path={URLS.APP_PATTERN}> <Route exact path={URLS.APP_PATTERN}>

View File

@@ -0,0 +1,12 @@
type ConnectionData = {
screenName: string;
}
type Connection = {
id: string;
key: string;
data: ConnectionData;
verified: boolean;
};
export type { Connection, ConnectionData };