Refresh tweaks (#80)

* add alerts to mock api dev server

add webhook methods types

fix focus for file upload

update contrib readme

blob fetching

rest props spread for file-upload

* multi element fieldset structure, unique basic auth field names for accounts form

* Fix and simplify webhook state setting

* some ad-hoc cleanup for temp work
This commit is contained in:
Brandon Lee Kitajchuk
2022-08-15 14:09:35 -07:00
committed by GitHub
parent cb48fb3bb7
commit ddc3404d5d
22 changed files with 597 additions and 259 deletions

View File

@@ -15,49 +15,54 @@
## :rocket: Getting started
Before running the web app you'll need to complete your local environment setup
In order to run the web app you'll need to complete your local environment setup
which you can do following instructions in our [environment readme](./environment.md).
Once your environment is setup you can fork or clone this repo. To start the web app
just run `npm i` and then `npm start`.
just run `npm install` and then `npm start`.
## :pancakes: Dev stack
We're using [vite](https://vitejs.dev/) for development. The main application is
[react](https://reactjs.org/docs/getting-started.html) aliasing [preact/compat](https://preactjs.com/guide/v10/switching-to-preact#setting-up-compat)
for `production` builds. For code sanity we're using [typescript](https://www.typescriptlang.org/),
[prettier](https://prettier.io/) and [eslint](https://eslint.org/). For code guarding we've implemented [husky](https://typicode.github.io/husky/#/)
and [lint-staged](https://www.npmjs.com/package/lint-staged) to check your changes before committing.
We're using [vite](https://vitejs.dev/) for development.
The main application is [react](https://reactjs.org/docs/getting-started.html)
aliasing [preact/compat](https://preactjs.com/guide/v10/switching-to-preact#setting-up-compat)
for production builds. For code we're using [typescript](https://www.typescriptlang.org/),
[prettier](https://prettier.io/), [eslint](https://eslint.org/),
[husky](https://typicode.github.io/husky/#/)
and [lint-staged](https://www.npmjs.com/package/lint-staged).
## :lock: Auth middleware
We have auth middleware implemented at the router level. It was initially based on this
[useAuth](https://usehooks.com/useAuth/) example but has been typed as well as modified to include
a `RequireAuth` component for wrapping internal `Routes`. The main hook you'll use here is simply `useAuth()`.
This hook provides the `AuthStateContext` which has the following:
We have auth middleware that was initially based on this [useAuth](https://usehooks.com/useAuth/)
example but has been typed as well as modified to include a RequireAuth component for wrapping internal Routes.
The main hook you'll use here is simply `useAuth`. This hook provides the `AuthStateContext` which has the
following:
- `token`: current auth token
- `token`: current auth bearer token
- `signin(user, pass)`: function to log the user in
- `signout()`: function to log the user out
- `authorized`: boolean dictating authorized status
- `authorized`: boolean dictating authorized status, e.g. valid token
#### A note on internal ACL implementation
### A note on our ACL implementation
We have some simple ACL utilities for managing access to UI/routes based on criteria. There is a basic
`AccessControl` component and a handy `withAccessControl(acl)(Component)` HOC for route containers with
redirect. There is also a `useAccessControl(acl)` hook for use at the component-level.
We have some simple ACL utilities for managing access to UI/routes based on conditions.
There is a basic AccessControl component and a handy `withAccessControl`
HOC for route containers with redirect. There is also a `useAccessControl` hook for
use at the component-level.
## :joystick: Application state
We're following the model `ui = fn(state)`. The state for the application has two parts: the local
state and the remote server state. The server state is the source of truth. We keep only the minimal amount
of local state necessary: the current logged in `user`, the list of `serviceProviders` and the actively
selected `currentServiceProvider`. We also use local state for a basic `ACL` permissions matrix and for the
global `toast` notifications. That's it! Local state is easy.
We're following the model `ui = fn(state)`. The state for the application has two parts:
the local state and the remote server state. The server state is the source of truth. We
keep only the minimal amount of local state necessary: the current logged in user, the
list of service providers and the actively selected current service provider. We also use
local state for a basic permissions matrix and for the global toast notifications.
That's it! Local state is easy.
Because of this limited scope for local state we're **not using a third-party state manager**. We have a
custom store implementation using vanilla React `useReducer` and `useContext` to provide the state to our
application. There are many useful functions and hooks for working with state which include the following:
Because of this limited scope for local state we're **not using a third-party state manager**.
We have a custom store implementation using vanilla React `useReducer` and context to provide
the state to our application. There are many useful functions and hooks for working with state
which include the following:
- `useStateContext()`: returns the entire state object
- `useSelectState(key)`: returns just the piece of state desired
@@ -68,47 +73,69 @@ application. There are many useful functions and hooks for working with state wh
- `toastError(msg)`: helper for dispatching error toasts
- `toastSuccess(msg)`: helper for dispatching success toasts
#### A note on feature flags
### A note on feature flags
Feature flags are managed via [Vite's environment variables](https://vitejs.dev/guide/env-and-mode.html#env-files).
They should be implemented as `VITE_FEATURE_{feature}`. They then need to be added to the `FeatureFlag` store interface
and their implementation should be added to the initial state object. See the `subspace` feature flag implementation
for an example of how to do this.
Feature flags are enabled via [Vite's environment variables](https://vitejs.dev/guide/env-and-mode.html#env-files).
They should be implemented as `VITE_FEATURE_{THING}`. They then need to be added to
the FeatureFlag store interface and their implementation should be added to the initial
state object.
#### A note here on type-safety
### A note here on type-safety
Our action interface receives a generic type argument that is mapped to the type parameter for the action.
This generic type is also used for indexed access to the state interface for the action payload parameter.
We use a global dispatch middleware that also receives this type argument and passes it to the action.
With this setup our app dispatch enforces that the type of payload maps to the type parameter received.
Our action interface receives a generic type argument that is mapped to the type
parameter for the action. This generic type is also used for indexed access to the
state interface for the action payload parameter. We use global dispatch middleware
to pass this type to the action. With this setup our app dispatch enforces that the
type of payload maps to the type parameter received.
## :wales: API implementation
We have a normalized API implementation. It should be adhered to at all times. Always use API helpers
that use `fetchTransport` under the hood and make use of our generic `use` hooks for most general fetch,
e.g. `GET`, requests. Here are a couple useful hooks for general `GET` fetching. Both methods the `data` fetched,
a `refetcher` function that, when called, will update the data in the hook and update your component with
the newly fetched data and a possible `error` if the fetch failed.
We have a normalized API implementation the app adheres to at all times, always
using the API helpers that call `fetchTransport` under the hood and making use of
our generic `use` hooks for most general fetch, e.g. `GET`, requests.
Our `use` hooks for general `GET` fetching return the `data` fetched, a `refetcher`
function that, when called, will update the data in the hook and therefore your component
will render the new data and a possible `error` if the fetch failed. The hooks are:
- `getFetch(url)`: returns `fetchTransport` for an `:GET` api request
- `useApiData(path)`: returns `[data, refetcher, error]`
- `useServiceProviderData(path)`: returns `[data, refetcher, error]`
#### A note here on type-safety
### A note here on type-safety
All API requests are piped through the `fetchTransport` method. Most `GET` requests can be done with our
normalized `use` hooks. Any `POST`, `PUT` or `DELETE` calls should have a helper method that uses `fetchTransport`
under the hood. Examples are `putUser` or `deleteApiKey` etc. The `fetchTransport` function receives a generic
type and returns it as the type of response data. This ensures type-safety when using API data at the component level.
All API requests are piped through the `fetchTransport` method. Most `GET` requests
can be done with our normalized `use` hooks. Any `POST`, `PUT` or `DELETE` calls should
have a helper method that calls more generic methods under the hood. The `fetchTransport`
function receives a generic type and returns it as the type of response data. This ensures
type-safety when using API data at the component level.
We do have four generic helper methods that most named API methods are simply wrappers for
as well as a method for fetching blob data. All methods resolve a FetchTransport.
- `getFetch(url)`
- `postFetch(url, payload)`
- `putFetch(url, payload)`
- `deleteFetch(url)`
- `getBlob(url)`
But you should create a named helper for use within components such as this example
so that API usage at the component level is really streamlined as much as possible.
```ts
export const postAccount = (payload: Payload) => {
return postFetch<SidResponse>(API_ACCOUNTS, payload);
};
```
## :file_folder: Vendor data modules
Large data modules are used for menu options on the `Applications` and `Speech Services` forms. These modules should be
loaded lazily and set to local state in the context in which they are used. This pattern should be followed for
any new data modules implemented in the application. You can find the data modules and their type definitions in
the `src/vendor` directory. A basic example of how this is done on the speech form:
Large data modules are used for menu options on the Applications and Speech Services
forms. These modules should be loaded lazily and set to local state in the context in which
they are used. This pattern should be followed for any new data modules implemented in the
application. You can find the data modules and their type definitions in the `src/vendor`
directory. A basic example of how this is done on the speech form:
```jsx
```tsx
/** Assume setup code for a component... */
/** Lazy-load large data schemas -- e.g. code-splitting */
@@ -135,27 +162,31 @@ useEffect(() => {
## :sunrise: Component composition
All components that are used as `Route` elements are considered `containers`. You'll notice that all containers
are lazy loaded at the router level, other than the root Login container, and this practice should always be
followed.
All components that are used as Route elements are considered `containers`.
You'll notice that all containers are lazy loaded at the router level, other
than the root Login container, and this practice should always be followed.
Containers are organized by `login` and `internal`, the latter of which requires the user to be authorized via
our auth middleware layer. Reusable components are small with specific pieces of functionality and their own local
state. We have plenty of examples including `toast`, `modal` and so forth. You should review some of each category
of component to get an idea of how the models are put into practice.
Containers are organized by `login` and `internal`, the latter of which requires
the user to be authorized via our auth middleware layer. Reusable components are
small with specific pieces of functionality and their own local state. We have
plenty of examples including `toast`, `modal` and so forth. You should review some
of each category of component to get an idea of how the patterns are put into practice.
## :art: UI and styling
We have a UI design system called [jambonz-ui](https://github.com/jambonz/jambonz-ui). It's public on `npm` and
is being used for this project. It's still small and simple but provides the foundational package content for
building jambonz UIs. You can see an example page of elements [here](https://www.jambonz.org/jambonz-ui/) as well
as view the docs for it [here](https://www.jambonz.org/docs/jambonz-ui/).
We have a UI design system called [jambonz-ui](https://github.com/jambonz/jambonz-ui).
It's public on `npm` and is being used for this project. It's still small and simple
but provides the foundational package content for building jambonz UIs. You can view
the storybook for it [here](https://jambonz-ui.vercel.app/) as well as view the docs
for it [here](https://www.jambonz.org/docs/jambonz-ui/).
We're using the `scss` syntax for [sass](https://sass-lang.com/) and a [BEM](http://getbem.com/introduction/)
style of authorship. You can find the naming guidelines for our implementation [here](http://getbem.com/naming/).
An example of a module authored in this way would look like the following but you can check any of the `styles`
files in the `src` code to get more of an understanding. One thing: never use `@import`! Always use `@use` for
sass modules.
### A note on styles
While we use [sass](https://sass-lang.com/) with `scss` syntax it should be stated that the
primary objective is to simply write generally pure `css`. We take advantage of a few nice
features of `sass` like nesting for [BEM](http://getbem.com/naming/) module style etc. We
also take advantage of loading the source `sass` from the UI library. Here's an example of
the `BEM` style we use:
```scss
.example {
@@ -182,9 +213,11 @@ If you would like to contribute to this project please follow these simple guide
- Be excellent to each other!
- Follow the best practices and coding standards outlined here.
- Clone or fork this repo, write code and open a PR :+1:
- All code must pass the `pr-checks` and be reviewed by a code owner.
That's it. Peer review will be done by codeowners and all we ask is that you please be patient!
That's it!
## :beetle: Bugs?
If you find a bug please file an issue on this repository :pray:.
If you find a bug please file an issue on this repository with as much information as
possible regarding replication etc :pray:.

2
package-lock.json generated
View File

@@ -45,7 +45,7 @@
"vite": "^3.0.0"
},
"engines": {
"node": ">=14"
"node": ">=14.18"
}
},
"node_modules/@ampproject/remapping": {

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"type": "module",
"engines": {
"node": ">=14"
"node": ">=14.18"
},
"contributors": [
{
@@ -16,10 +16,10 @@
],
"scripts": {
"prepare": "husky install",
"postinstall": "rm -rf public/fonts && cp -R ./node_modules/jambonz-ui/public/fonts ./public/fonts",
"postinstall": "rm -rf public/fonts && cp -R node_modules/jambonz-ui/public/fonts public/fonts",
"start": "npm run dev",
"dev": "vite --port 3001",
"dev:server": "ts-node --esm ./dev.server.ts",
"dev:server": "ts-node --esm server/dev.server.ts",
"prebuild": "rm -rf ./dist",
"build": "tsc && vite build",
"preview": "vite preview",

View File

@@ -1,8 +1,10 @@
import fs from "fs";
import path from "path";
import cors from "cors";
import express from "express";
import type { Request, Response } from "express";
import type { RecentCall, PagedResponse } from "./src/api/types";
import type { Alert, RecentCall, PagedResponse } from "../src/api/types";
const app = express();
const port = 3002;
@@ -45,6 +47,44 @@ app.get(
}
);
app.get(
"/api/Accounts/:account_sid/RecentCalls/:call_sid/pcap",
(req: Request, res: Response) => {
/** Sample pcap file from: https://wiki.wireshark.org/SampleCaptures#sip-and-rtp */
const pcap: Buffer = fs.readFileSync(
path.resolve(process.cwd(), "server", "sample-sip-rtp-traffic.pcap")
);
res
.status(200)
.set({
"Content-Type": "application/octet-stream",
"Content-Disposition": "attachment",
})
.send(pcap); // server: Buffer => client: Blob
}
);
app.get("/api/Accounts/:account_sid/Alerts", (req: Request, res: Response) => {
const alert: Alert = {
account_sid: req.params.account_sid,
time: "2022-08-12T22:52:28.110Z",
alert_type: "string",
message: "string",
detail: "string",
};
const total = 50;
/** Simple dumb hack to populate mock data for responses... */
const data = new Array(total).fill(alert, 0, total);
res.status(200).json(<PagedResponse<Alert>>{
total,
batch: 0,
page: 0,
data,
});
});
app.listen(port, () => {
console.log(`express server listening on port ${port}`);
});

View File

@@ -0,0 +1,4 @@
{
"private_key": "qwertyuiopasdfghjklzxcvbnm",
"client_email": "foo@bar.com"
}

Binary file not shown.

View File

@@ -1,4 +1,4 @@
import type { WebHook } from "./types";
import type { WebHook, WebhookOption } from "./types";
/** This window object is serialized and injected at docker runtime */
/** The API url is constructed with the docker containers `ip:port` */
@@ -27,6 +27,18 @@ export const DEFAULT_WEBHOOK: WebHook = {
password: "",
};
/** Available webhook methods */
export const WEBHOOK_METHODS: WebhookOption[] = [
{
name: "POST",
value: "POST",
},
{
name: "GET",
value: "GET",
},
];
/** API base paths */
export const API_LOGIN = `${API_BASE_URL}/login`;
export const API_SBCS = `${API_BASE_URL}/Sbcs`;
@@ -69,7 +81,7 @@ export const API_SERVICE_PROVIDERS = `${API_BASE_URL}/ServiceProviders`;
:GET /Accounts/:account_sid/ApiKeys
:GET /Accounts/WebhookSecret?regenerate=true
:PUT /Accounts/:account_sid
/internal/applications
:GET /ServiceProviders

View File

@@ -18,7 +18,7 @@ import {
MSG_SERVER_DOWN,
MSG_SOMETHING_WRONG,
} from "src/constants";
import { PagedResponse, RecentCall, StatusCodes } from "./types";
import { StatusCodes } from "./types";
import type {
FetchError,
@@ -32,6 +32,9 @@ import type {
EmptyResponse,
SecretResponse,
UseApiData,
Alert,
PagedResponse,
RecentCall,
} from "./types";
/** Wrap all requests to normalize response handling */
@@ -42,7 +45,7 @@ const fetchTransport = <Type>(
return new Promise(async (resolve, reject) => {
try {
const response = await fetch(url, options);
const transport = {
const transport: FetchTransport<Type> = {
status: response.status,
json: <Type>{},
};
@@ -80,9 +83,19 @@ const fetchTransport = <Type>(
response.status === StatusCodes.OK ||
response.status === StatusCodes.CREATED
) {
const json: Type = await response.json();
// Handle blobs -- e.g. pcap file API for RecentCalls
if (
options.headers!["Content-Type" as keyof HeadersInit] ===
"application/octet-stream"
) {
const blob: Blob = await response.blob();
transport.json = json;
transport.blob = blob;
} else {
const json: Type = await response.json();
transport.json = json;
}
}
resolve(transport);
@@ -133,6 +146,17 @@ const handleUnreachable = () => {
handleBadRequest(MSG_SERVER_DOWN);
};
/** Wrapper for fetching Blobs -- API use case is RecentCalls pcap files */
export const getBlob = (url: string) => {
return fetchTransport(url, {
headers: {
...getAuthHeaders(),
"Content-Type": "application/octet-stream",
},
});
};
/** Simple wrappers for fetchTransport calls to any API, :GET, :POST, :PUT, :DELETE */
export const getFetch = <Type>(url: string) => {
@@ -255,6 +279,22 @@ export const getRecentCalls = (sid: string) => {
);
};
export const getPcap = (sid: string, callSid: string) => {
return getBlob(
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${callSid}/pcap`
: `${API_ACCOUNTS}/${sid}/RecentCalls/${callSid}/pcap`
);
};
export const getAlerts = (sid: string) => {
return getFetch<PagedResponse<Alert>>(
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/Alerts`
: `${API_ACCOUNTS}/${sid}/Alerts`
);
};
/** Hooks for components to fetch data with refetch method */
/** :GET /{apiPath} -- this is generic for any fetch of data collections */

View File

@@ -2,6 +2,8 @@ export type Payload = Record<string, unknown>;
export type EmptyResponse = Payload;
export type WebhookMethod = "POST" | "GET";
/** Status codes with JSON responses */
/** Code 400,403 can be an empty response sometimes... */
/** FYI: Code 480 is SMPP temporarily unavailable */
@@ -30,6 +32,7 @@ export enum StatusCodes {
export interface FetchTransport<Type> {
status: StatusCodes;
json: Type;
blob?: Blob;
}
export interface FetchError {
@@ -68,12 +71,17 @@ export interface ApiKey {
export interface WebHook {
url: string;
method: string;
method: WebhookMethod;
username: null | string;
password: null | string;
webhook_sid?: null | string;
}
export interface WebhookOption {
name: WebhookMethod;
value: WebhookMethod;
}
export interface Sbc {
ipv4: string;
port: number | string;
@@ -157,6 +165,19 @@ export interface RecentCall {
trunk: string;
}
export interface Pcap {
data_url: string;
file_name: string;
}
export interface Alert {
time: string;
account_sid: string;
alert_type: string;
message: string;
detail: string;
}
export interface PagedResponse<Type> {
total: number;
batch: number;

View File

@@ -1,4 +1,5 @@
import React, { useState, forwardRef } from "react";
import { classNames } from "jambonz-ui";
import { Icons } from "src/components/icons";
@@ -10,13 +11,27 @@ type FileProps = JSX.IntrinsicElements["input"] & {
type FileRef = HTMLInputElement;
/** The forwarded ref is so forms can still focus() this select menu if necessary... */
/** The forwarded ref is so forms can still focus() this input if necessary... */
/** Disabling the cosmetic text input seems the best way to remove it from the field... */
/** Worth noting that tabIndex -1 with readOnly works as well, but disabled nukes it! */
/** Passing rest props for things like `required` etc for form handling / validation */
export const FileUpload = forwardRef<FileRef, FileProps>(
(
{ id, name, handleFile, placeholder = "No file chosen" }: FileProps,
{
id,
name,
handleFile,
placeholder = "No file chosen",
...restProps
}: FileProps,
ref
) => {
const [fileName, setFileName] = useState("");
const [focus, setFocus] = useState(false);
const classes = {
"file-upload": true,
focused: focus,
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length) {
@@ -26,22 +41,23 @@ export const FileUpload = forwardRef<FileRef, FileProps>(
};
return (
<div className="file-upload">
<div className={classNames(classes)}>
<div className="file-upload__wrap inpbtn">
<input
ref={ref}
id={id}
name={name}
type="file"
onChange={handleChange}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
{...restProps}
/>
<input
type="text"
value={fileName}
placeholder={placeholder}
readOnly
/>
<input
id={`file_${id}`}
name={`file_${name}`}
type="file"
onChange={handleChange}
disabled
/>
<button className="btn--type" type="button" title={placeholder}>
<Icons.FilePlus />

View File

@@ -12,6 +12,13 @@
}
}
&.focused {
input[type="text"] {
border-color: ui-vars.$dark;
color: ui-vars.$dark;
}
}
input[type="file"] {
cursor: pointer;
height: 100%;

View File

@@ -31,7 +31,11 @@
}
main {
width: 100%;
width: calc(100% - #{vars.$widthnavi});
@include mixins.mobile() {
width: 100%;
}
article {
padding: 0 ui-vars.$width-padding ui-vars.$px04;
@@ -41,7 +45,7 @@
/** forms */
form {
> * + * {
margin-top: ui-vars.$px02;
border-top: 1px solid ui-vars.$grey;
}
label {
@@ -71,14 +75,46 @@
display: flex;
justify-content: flex-start;
flex-direction: column;
padding: ui-vars.$px03;
label {
max-width: fit-content;
}
> * + * {
label + * {
margin-top: ui-vars.$px01;
}
> div + div {
margin-top: ui-vars.$px02;
}
.multi {
display: flex;
align-items: center;
@media (max-width: 1090px) {
flex-direction: column;
align-items: flex-start;
}
> .inp {
width: 100%;
max-width: vars.$widthinput;
}
> .sel {
width: 100%;
max-width: 130px;
margin-left: ui-vars.$px01;
@media (max-width: 1090px) {
margin-left: 0;
margin-top: ui-vars.$px01;
max-width: vars.$widthinput;
}
}
}
}
input[type="text"],

View File

@@ -20,7 +20,13 @@ import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import { DEFAULT_WEBHOOK } from "src/api/constants";
import { Subspace } from "./subspace";
import type { WebHook, Account, FetchError, Application } from "src/api/types";
import type {
WebHook,
Account,
FetchError,
Application,
WebhookMethod,
} from "src/api/types";
export type UseAccountData = {
data: Account | null;
@@ -120,20 +126,6 @@ export const AccountForm = ({
}
};
/** Real generic way of managing form state for both hooks */
const handleSetHook = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
hook: WebHook,
setHook: (h: WebHook) => void
) => {
if (hook) {
setHook({
...hook,
[e.target.name]: e.target.value,
});
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -276,7 +268,7 @@ export const AccountForm = ({
return (
<>
<Section>
<Section slim>
<form onSubmit={handleSubmit}>
{account && account.data && (
<fieldset>
@@ -389,59 +381,83 @@ export const AccountForm = ({
return (
<fieldset key={webhook.prefix}>
<label htmlFor={`${webhook.prefix}_url`}>
{webhook.label} webhook
</label>
<input
id={`${webhook.prefix}_url`}
type="text"
name="url"
placeholder={`${webhook.label} webhook`}
value={webhook.stateVal?.url}
onChange={(e) =>
handleSetHook(e, webhook.stateVal, webhook.stateSet)
}
/>
<label htmlFor={`${webhook.prefix}_method`}>Method</label>
<Selector
id={`${webhook.prefix}_method`}
name="method"
value={webhook.stateVal?.method}
onChange={(e) =>
handleSetHook(e, webhook.stateVal, webhook.stateSet)
}
options={selectOptions}
/>
<Checkzone
hidden
name={webhook.prefix}
label="Use HTTP Basic Authentication"
initialCheck={webhook.initialCheck}
>
<label htmlFor={`${webhook.prefix}_username`}>Username</label>
<input
ref={webhook.refUser}
id={`${webhook.prefix}_username`}
type="text"
name="username"
placeholder="Optional"
value={webhook.stateVal?.username || ""}
onChange={(e) =>
handleSetHook(e, webhook.stateVal, webhook.stateSet)
}
/>
<label htmlFor={`${webhook.prefix}_password`}>Password</label>
<Passwd
ref={webhook.refPass}
id={`${webhook.prefix}_password`}
name="password"
value={webhook.stateVal?.password || ""}
placeholder="Optional"
onChange={(e) =>
handleSetHook(e, webhook.stateVal, webhook.stateSet)
}
/>
</Checkzone>
<div className="multi">
<div className="inp">
<label htmlFor={`${webhook.prefix}_url`}>
{webhook.label} webhook
</label>
<input
id={`${webhook.prefix}_url`}
type="text"
name={`${webhook.prefix}_url`}
placeholder={`${webhook.label} webhook`}
value={webhook.stateVal?.url}
onChange={(e) => {
webhook.stateSet({
...webhook.stateVal,
url: e.target.value,
});
}}
/>
</div>
<div className="sel">
<label htmlFor={`${webhook.prefix}_method`}>Method</label>
<Selector
id={`${webhook.prefix}_method`}
name={`${webhook.prefix}_method`}
value={webhook.stateVal?.method}
onChange={(e) => {
webhook.stateSet({
...webhook.stateVal,
method: e.target.value as WebhookMethod,
});
}}
options={selectOptions}
/>
</div>
</div>
<div>
<Checkzone
hidden
name={webhook.prefix}
label="Use HTTP Basic Authentication"
initialCheck={webhook.initialCheck}
>
<label htmlFor={`${webhook.prefix}_username`}>
Username
</label>
<input
ref={webhook.refUser}
id={`${webhook.prefix}_username`}
type="text"
name={`${webhook.prefix}_username`}
placeholder="Optional"
value={webhook.stateVal?.username || ""}
onChange={(e) => {
webhook.stateSet({
...webhook.stateVal,
username: e.target.value,
});
}}
/>
<label htmlFor={`${webhook.prefix}_password`}>
Password
</label>
<Passwd
ref={webhook.refPass}
id={`${webhook.prefix}_password`}
name={`${webhook.prefix}_password`}
value={webhook.stateVal?.password || ""}
placeholder="Optional"
onChange={(e) => {
webhook.stateSet({
...webhook.stateVal,
password: e.target.value,
});
}}
/>
</Checkzone>
</div>
</fieldset>
);
})}
@@ -453,20 +469,26 @@ export const AccountForm = ({
sipRealm={realm}
/>
)}
{message && <Message message={message} />}
<ButtonGroup left>
<Button
small
subStyle="grey"
as={Link}
to={ROUTE_INTERNAL_ACCOUNTS}
>
Cancel
</Button>
<Button type="submit" small>
Save
</Button>
</ButtonGroup>
{message && (
<fieldset>
<Message message={message} />
</fieldset>
)}
<fieldset>
<ButtonGroup left>
<Button
small
subStyle="grey"
as={Link}
to={ROUTE_INTERNAL_ACCOUNTS}
>
Cancel
</Button>
<Button type="submit" small>
Save
</Button>
</ButtonGroup>
</fieldset>
</form>
</Section>
{modal && (

View File

@@ -1,8 +1,42 @@
import React from "react";
import { H1 } from "jambonz-ui";
import React, { useEffect } from "react";
import { H1, P } from "jambonz-ui";
import { getAlerts } from "src/api";
import { toastError } from "src/store";
import { Section } from "src/components";
export const Alerts = () => {
return <H1>Alerts</H1>;
useEffect(() => {
let ignore = false;
getAlerts("foo-sid")
.then(({ json }) => {
if (!ignore) {
console.log(json);
}
})
.catch((error) => {
toastError(error.msg);
});
return function cleanup() {
ignore = true;
};
}, []);
return (
<>
<H1>Alerts</H1>
<Section>
<P>Example using a test dev server for mocked API responses.</P>
<P>
To run the dev mock api server run <code>npm run dev:server</code>.
</P>
<P>If the dev server is not running you will get an error toast.</P>
<P>Otherwise check the browser console to see the data logged...</P>
</Section>
</>
);
};
export default Alerts;

View File

@@ -77,24 +77,26 @@ export const Applications = () => {
return (
<>
<H1>Applications</H1>
<Section>
<Section slim>
<form>
<P>Example of lazy loading speech data files for add/edit form.</P>
<P>
This also shows how to implement the speech selector logic for
vendors.
</P>
<P>
Selected synthesis vendor:{" "}
<strong>{vendorSynth || "undefined"}</strong>.
</P>
<P>
Selected synthesis language:{" "}
<strong>{langSynth || "undefined"}</strong>.
</P>
<P>
Selected synthesis voice: <strong>{voice || "undefined"}</strong>.
</P>
<fieldset>
<P>Example of lazy loading speech data files for add/edit form.</P>
<P>
This also shows how to implement the speech selector logic for
vendors.
</P>
<P>
Selected synthesis vendor:{" "}
<strong>{vendorSynth || "undefined"}</strong>.
</P>
<P>
Selected synthesis language:{" "}
<strong>{langSynth || "undefined"}</strong>.
</P>
<P>
Selected synthesis voice: <strong>{voice || "undefined"}</strong>.
</P>
</fieldset>
{synthesis && (
<>
<fieldset>
@@ -196,14 +198,16 @@ export const Applications = () => {
)}
</>
)}
<P>
Selected recognizer vendor:{" "}
<strong>{vendorRecog || "undefined"}</strong>.
</P>
<P>
Selected recognizer language:{" "}
<strong>{langRecog || "undefined"}</strong>.
</P>
<fieldset>
<P>
Selected recognizer vendor:{" "}
<strong>{vendorRecog || "undefined"}</strong>.
</P>
<P>
Selected recognizer language:{" "}
<strong>{langRecog || "undefined"}</strong>.
</P>
</fieldset>
{recognizers && (
<>
<fieldset>

View File

@@ -2,7 +2,7 @@ import React from "react";
import { H1 } from "jambonz-ui";
export const PhoneNumbers = () => {
return <H1>Phone Numbers</H1>;
return <H1>Phone Number Routing</H1>;
};
export default PhoneNumbers;

View File

@@ -1,18 +1,23 @@
import React, { useEffect } from "react";
import { H1, P } from "jambonz-ui";
import { getRecentCalls } from "src/api";
import { toastError } from "src/store";
import React, { useEffect, useState } from "react";
import { Button, H1, P } from "jambonz-ui";
import { getRecentCalls, getPcap } from "src/api";
import { toastError } from "src/store";
import { Section } from "src/components";
import type { Pcap, RecentCall } from "src/api/types";
export const RecentCalls = () => {
const [pcap, setPcap] = useState<Pcap | null>(null);
const [calls, setCalls] = useState<RecentCall[] | null>(null);
useEffect(() => {
let ignore = false;
getRecentCalls("foo-sid")
getRecentCalls("account-sid")
.then(({ json }) => {
if (!ignore) {
console.log(json);
setCalls(json.data);
}
})
.catch((error) => {
@@ -32,12 +37,48 @@ export const RecentCalls = () => {
<P>
To run the dev mock api server run <code>npm run dev:server</code>.
</P>
<P>
You can see the dev server implementation in{" "}
<code>dev.server.ts</code>
</P>
<P>If the dev server is not running you will get an error toast.</P>
<P>Otherwise check the browser console to see the data logged...</P>
<P>&nbsp;</P>
<P>
Also this page shows how to fetch a <span>pcap</span> file from the
API:
</P>
<div className="p">
Selected pcap state object:{" "}
{pcap ? (
<>
<pre>{JSON.stringify(pcap, null, 2)}</pre>
<a href={pcap.data_url} download={pcap.file_name}>
Download pcap file
</a>
</>
) : (
<strong>undefined</strong>
)}
</div>
<P>&nbsp;</P>
{calls && (
<Button
small
onClick={() => {
getPcap(calls[0].account_sid, calls[0].call_sid)
.then(({ blob }) => {
if (blob) {
setPcap({
data_url: URL.createObjectURL(blob),
file_name: `callid-${calls[0].sip_call_id}.pcap`,
});
}
})
.catch((error) => {
toastError(error.msg);
});
}}
>
Fetch pcap
</Button>
)}
</Section>
</>
);

View File

@@ -101,7 +101,7 @@ export const Settings = ({
return (
<>
<H1>Settings</H1>
<Section>
<Section slim>
<form onSubmit={handleSubmit}>
<fieldset>
<label htmlFor="name">Service provider name</label>
@@ -115,13 +115,13 @@ export const Settings = ({
onChange={(e) => setName(e.target.value)}
/>
</fieldset>
<Checkzone
name="teams"
label="Enable MS Teams Direct Routing"
initialCheck={initialCheck}
handleChecked={handleChecked}
>
<fieldset>
<fieldset>
<Checkzone
name="teams"
label="Enable MS Teams Direct Routing"
initialCheck={initialCheck}
handleChecked={handleChecked}
>
<label htmlFor="ms_teams_fqdn">SBC domain name</label>
<input
id="ms_teams_fqdn"
@@ -131,18 +131,20 @@ export const Settings = ({
value={teams}
onChange={(e) => setTeams(e.target.value)}
/>
</fieldset>
</Checkzone>
<ButtonGroup left>
<Button type="submit" small>
Save
</Button>
{serviceProviders.length > 1 && (
<Button small subStyle="grey" onClick={handleConfirm}>
Delete
</Checkzone>
</fieldset>
<fieldset>
<ButtonGroup left>
<Button type="submit" small>
Save
</Button>
)}
</ButtonGroup>
{serviceProviders.length > 1 && (
<Button small subStyle="grey" onClick={handleConfirm}>
Delete
</Button>
)}
</ButtonGroup>
</fieldset>
</form>
</Section>
{currentServiceProvider && (

View File

@@ -39,27 +39,29 @@ export const SpeechServices = () => {
return (
<>
<H1>Speech Services</H1>
<Section>
<Section slim>
<form>
<P>Example of lazy loading region data files for add/edit form.</P>
<P>
This also shows how to implement the region selector logic for
aws/microsoft as well as service key file upload for google.
</P>
<P>
Selected vendor: <strong>{vendor || "undefined"}</strong>
</P>
<P>
Selected region: <strong>{region || "undefined"}</strong>
</P>
<div className="p">
Selected service key:{" "}
{serviceKey ? (
<pre>{JSON.stringify(serviceKey, null, 2)}</pre>
) : (
<strong>undefined</strong>
)}
</div>
<fieldset>
<P>Example of lazy loading region data files for add/edit form.</P>
<P>
This also shows how to implement the region selector logic for
aws/microsoft as well as service key file upload for google.
</P>
<P>
Selected vendor: <strong>{vendor || "undefined"}</strong>
</P>
<P>
Selected region: <strong>{region || "undefined"}</strong>
</P>
<div className="p">
Selected service key:{" "}
{serviceKey ? (
<pre>{JSON.stringify(serviceKey, null, 2)}</pre>
) : (
<strong>undefined</strong>
)}
</div>
</fieldset>
<fieldset>
<label htmlFor="vendor">Vendor</label>
<Selector

View File

@@ -24,3 +24,19 @@
height: vars.$ico00;
}
}
/** This should go into jambonz-ui? */
@mixin pre {
font-family: ui-vars.$font-mono;
font-size: ui-vars.$m-size;
line-height: 1.9;
@media (max-width: ui-vars.$width-tablet-2) {
font-size: ui-vars.$ms-size;
}
@media (max-width: ui-vars.$width-mobile) {
font-size: ui-vars.$mxs-size;
}
}

View File

@@ -1,8 +1,10 @@
@use "./vars";
@use "./cards";
@use "./lists";
@use "./inpbtn";
@use "jambonz-ui/src/styles/index";
@use "./vars";
@use "./mixins";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
@@ -41,7 +43,7 @@ input[type="checkbox"] {
pre,
code {
font-family: ui-vars.$font-mono;
@include mixins.pre();
}
pre {
@@ -49,7 +51,13 @@ pre {
color: ui-vars.$pink;
background-color: ui-vars.$dark;
border-radius: ui-vars.$px01;
font-size: ui-vars.$m-size;
}
p > code {
padding: ui-vars.$px00;
color: ui-vars.$pink;
background-color: ui-vars.$dark;
border-radius: ui-vars.$px00;
}
/** The idea is this is like [type="button"] generically */

View File

@@ -6,5 +6,5 @@
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "dev.server.ts"]
"include": ["vite.config.ts", "server/dev.server.ts"]
}