mirror of
https://github.com/jambonz/jambonz-webapp.git
synced 2025-12-19 05:37:43 +00:00
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:
committed by
GitHub
parent
cb48fb3bb7
commit
ddc3404d5d
@@ -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
2
package-lock.json
generated
@@ -45,7 +45,7 @@
|
||||
"vite": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
"node": ">=14.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
4
server/sample-google-service-key.json
Normal file
4
server/sample-google-service-key.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"private_key": "qwertyuiopasdfghjklzxcvbnm",
|
||||
"client_email": "foo@bar.com"
|
||||
}
|
||||
BIN
server/sample-sip-rtp-traffic.pcap
Normal file
BIN
server/sample-sip-rtp-traffic.pcap
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.focused {
|
||||
input[type="text"] {
|
||||
border-color: ui-vars.$dark;
|
||||
color: ui-vars.$dark;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> </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> </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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts", "dev.server.ts"]
|
||||
"include": ["vite.config.ts", "server/dev.server.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user