Skip to content

Commit

Permalink
UI for Create Postgres Peer (#456)
Browse files Browse the repository at this point in the history
- UI for Create Peer step where user enters configuration details
- Wired validate peer API to the UI
- Wired create peer API to the UI
- Form validation, loading text 
- Written in a way where adding new peers is easy
- Tested the create peer flow (including psql-ing the peer) for postgres

APIs for create peer and validate peer are present in
`app/api/peers`

Makes progress towards #431
  • Loading branch information
Amogh-Bharadwaj authored Oct 3, 2023
1 parent bb9deb7 commit 8a434aa
Show file tree
Hide file tree
Showing 17 changed files with 481 additions and 29 deletions.
54 changes: 54 additions & 0 deletions ui/app/api/peers/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { PeerConfig } from '@/app/peers/create/configuration/types';
import { DBType, Peer, PostgresConfig } from '@/grpc_generated/peers';
import {
CreatePeerRequest,
CreatePeerResponse,
CreatePeerStatus,
ValidatePeerRequest,
ValidatePeerResponse,
ValidatePeerStatus,
} from '@/grpc_generated/route';
import { GetFlowServiceClientFromEnv } from '@/rpc/rpc';

const constructPeer = (
name: string,
type: string,
config: PeerConfig
): Peer | undefined => {
switch (type) {
case 'POSTGRES':
return {
name,
type: DBType.POSTGRES,
postgresConfig: config as PostgresConfig,
};
default:
return;
}
};

export async function POST(request: Request) {
const body = await request.json();
const { name, type, config, mode } = body;
const flowServiceClient = GetFlowServiceClientFromEnv();
const peer = constructPeer(name, type, config);
if (mode === 'validate') {
const validateReq: ValidatePeerRequest = { peer };
const validateStatus: ValidatePeerResponse =
await flowServiceClient.validatePeer(validateReq);
if (validateStatus.status === ValidatePeerStatus.INVALID) {
return new Response(validateStatus.message);
} else if (validateStatus.status === ValidatePeerStatus.VALID) {
return new Response('valid');
}
} else if (mode === 'create') {
const req: CreatePeerRequest = { peer };
const createStatus: CreatePeerResponse =
await flowServiceClient.createPeer(req);
if (createStatus.status === CreatePeerStatus.FAILED) {
return new Response(createStatus.message);
} else if (createStatus.status === CreatePeerStatus.CREATED) {
return new Response('created');
} else return new Response('status of peer creation is unknown');
} else return new Response('mode of peer creation is unknown');
}
40 changes: 40 additions & 0 deletions ui/app/peers/create/configuration/configForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use client';
import { Label } from '@/lib/Label';
import { RowWithTextField } from '@/lib/Layout';
import { TextField } from '@/lib/TextField';
import { PeerSetter } from './types';

interface Setting {
label: string;
stateHandler: (value: string, setter: PeerSetter) => void;
type?: string;
}

interface ConfigProps {
settings: Setting[];
setter: PeerSetter;
}

export default function ConfigForm(props: ConfigProps) {
return (
<>
{props.settings.map((setting, id) => {
return (
<RowWithTextField
key={id}
label={<Label as='label'>{setting.label}</Label>}
action={
<TextField
variant='simple'
type={setting.type}
onChange={(e) =>
setting.stateHandler(e.target.value, props.setter)
}
/>
}
/>
);
})}
</>
);
}
93 changes: 93 additions & 0 deletions ui/app/peers/create/configuration/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context';
import { Dispatch, SetStateAction } from 'react';
import { pgSchema } from './schema';
import { PeerConfig } from './types';

// Frontend form validation
const validateFields = (
type: string,
config: PeerConfig,
setMessage: Dispatch<SetStateAction<{ ok: boolean; msg: string }>>,
name?: string
): boolean => {
if (!name) {
setMessage({ ok: false, msg: 'Peer name is required' });
return false;
}
let validationErr: string | undefined;
switch (type) {
case 'POSTGRES':
const pgConfig = pgSchema.safeParse(config);
if (!pgConfig.success) validationErr = pgConfig.error.issues[0].message;
break;
default:
validationErr = 'Unsupported peer type ' + type;
}
if (validationErr) {
setMessage({ ok: false, msg: validationErr });
return false;
} else setMessage({ ok: true, msg: '' });
return true;
};

// API call to validate peer
export const handleValidate = async (
type: string,
config: PeerConfig,
setMessage: Dispatch<SetStateAction<{ ok: boolean; msg: string }>>,
setLoading: Dispatch<SetStateAction<boolean>>,
name?: string
) => {
const isValid = validateFields(type, config, setMessage, name);
if (!isValid) return;
setLoading(true);
const statusMessage = await fetch('/api/peers/', {
method: 'POST',
body: JSON.stringify({
name,
type,
config,
mode: 'validate',
}),
}).then((res) => res.text());
if (statusMessage !== 'valid') {
setMessage({ ok: false, msg: statusMessage });
setLoading(false);
return;
} else {
setMessage({ ok: true, msg: 'Peer is valid' });
}
setLoading(false);
};

// API call to create peer
export const handleCreate = async (
type: string,
config: PeerConfig,
setMessage: Dispatch<SetStateAction<{ ok: boolean; msg: string }>>,
setLoading: Dispatch<SetStateAction<boolean>>,
router: AppRouterInstance,
name?: string
) => {
let isValid = validateFields(type, config, setMessage, name);
if (!isValid) return;
setLoading(true);
const statusMessage = await fetch('/api/peers/', {
method: 'POST',
body: JSON.stringify({
name,
type,
config,
mode: 'create',
}),
}).then((res) => res.text());
if (statusMessage !== 'created') {
setMessage({ ok: false, msg: statusMessage });
setLoading(false);
return;
} else {
setMessage({ ok: true, msg: 'Peer created successfully' });
router.push('/peers');
}
setLoading(false);
};
10 changes: 10 additions & 0 deletions ui/app/peers/create/configuration/helpers/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { blankPostgresSetting } from './pg';

export const getBlankSetting = (dbType: string) => {
switch (dbType) {
case 'POSTGRES':
return blankPostgresSetting;
default:
return blankPostgresSetting;
}
};
45 changes: 45 additions & 0 deletions ui/app/peers/create/configuration/helpers/pg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { PeerSetter } from '../types';

export const postgresSetting = [
{
label: 'Host',
stateHandler: (value: string, setter: PeerSetter) =>
setter((curr) => ({ ...curr, host: value })),
},
{
label: 'Port',
stateHandler: (value: string, setter: PeerSetter) =>
setter((curr) => ({ ...curr, port: parseInt(value, 10) })),
type: 'number', // type for textfield
},
{
label: 'User',
stateHandler: (value: string, setter: PeerSetter) =>
setter((curr) => ({ ...curr, user: value })),
},
{
label: 'Password',
stateHandler: (value: string, setter: PeerSetter) =>
setter((curr) => ({ ...curr, password: value })),
type: 'password',
},
{
label: 'Database',
stateHandler: (value: string, setter: PeerSetter) =>
setter((curr) => ({ ...curr, database: value })),
},
{
label: 'Transaction Snapshot',
stateHandler: (value: string, setter: PeerSetter) =>
setter((curr) => ({ ...curr, transactionSnapshot: value })),
},
];

export const blankPostgresSetting = {
host: '',
port: 5432,
user: '',
password: '',
database: '',
transactionSnapshot: '',
};
110 changes: 110 additions & 0 deletions ui/app/peers/create/configuration/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
'use client';
import { Button } from '@/lib/Button';
import { ButtonGroup } from '@/lib/ButtonGroup';
import { Label } from '@/lib/Label';
import { LayoutMain, RowWithTextField } from '@/lib/Layout';
import { Panel } from '@/lib/Panel';
import { TextField } from '@/lib/TextField';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import ConfigForm from './configForm';
import { handleCreate, handleValidate } from './handlers';
import { getBlankSetting } from './helpers/common';
import { postgresSetting } from './helpers/pg';
import { PeerConfig } from './types';
export default function CreateConfig() {
const searchParams = useSearchParams();
const router = useRouter();
const dbType = searchParams.get('dbtype') || '';
const blankSetting = getBlankSetting(dbType);
const [name, setName] = useState<string>('');
const [config, setConfig] = useState<PeerConfig>(blankSetting);
const [formMessage, setFormMessage] = useState<{ ok: boolean; msg: string }>({
ok: true,
msg: '',
});
const [loading, setLoading] = useState<boolean>(false);
const configComponentMap = (dbType: string) => {
switch (dbType) {
case 'POSTGRES':
return <ConfigForm settings={postgresSetting} setter={setConfig} />;
default:
return <></>;
}
};

return (
<LayoutMain alignSelf='center' justifySelf='center' width='xxLarge'>
<Panel>
<Label variant='title3'>New peer</Label>
<Label colorName='lowContrast'>Set up a new peer.</Label>
</Panel>
<Panel>
<Label colorName='lowContrast' variant='subheadline'>
Configuration
</Label>
<RowWithTextField
label={<Label as='label'>Name</Label>}
action={
<TextField
variant='simple'
onChange={(e) => setName(e.target.value)}
/>
}
/>
{dbType && configComponentMap(dbType)}
</Panel>
<Panel>
<ButtonGroup>
<Button as={Link} href='/peers/create'>
Back
</Button>
<Button
style={{ backgroundColor: 'gold' }}
onClick={() =>
handleValidate(dbType, config, setFormMessage, setLoading, name)
}
>
Validate
</Button>
<Button
variant='normalSolid'
onClick={() =>
handleCreate(
dbType,
config,
setFormMessage,
setLoading,
router,
name
)
}
>
Create
</Button>
</ButtonGroup>
<Panel>
{loading && (
<Label
colorName='lowContrast'
colorSet='base'
variant='subheadline'
>
Validating...
</Label>
)}
{!loading && formMessage.msg.length > 0 && (
<Label
colorName='lowContrast'
colorSet={formMessage.ok ? 'positive' : 'destructive'}
variant='subheadline'
>
{formMessage.msg}
</Label>
)}
</Panel>
</Panel>
</LayoutMain>
);
}
Loading

0 comments on commit 8a434aa

Please sign in to comment.