mirror of
https://github.com/jambonz/jambonz-webapp.git
synced 2025-12-19 05:37:43 +00:00
Implement initial Cypress testing configuration (#115)
* Implement initial Cypress testing configuration * Add docs on cypress for testing * Cypress cache for GitHub actions
This commit is contained in:
committed by
GitHub
parent
a6aee3802c
commit
76857c0a4b
11
.github/workflows/pr-checks.yml
vendored
11
.github/workflows/pr-checks.yml
vendored
@@ -21,10 +21,21 @@ jobs:
|
|||||||
path: node_modules
|
path: node_modules
|
||||||
key: node-modules-${{ hashFiles('package-lock.json') }}
|
key: node-modules-${{ hashFiles('package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Cache cypress binary
|
||||||
|
id: cypress-cache
|
||||||
|
uses: actions/cache@v2
|
||||||
|
with:
|
||||||
|
path: /home/runner/.cache/Cypress
|
||||||
|
key: cypress-${{ hashFiles('package-lock.json') }}
|
||||||
|
|
||||||
- name: Install node_modules
|
- name: Install node_modules
|
||||||
if: steps.node-cache.outputs.cache-hit != 'true'
|
if: steps.node-cache.outputs.cache-hit != 'true'
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
|
- name: Install cypress
|
||||||
|
if: steps.cypress-cache.outputs.cache-hit != 'true'
|
||||||
|
run: npx cypress install
|
||||||
|
|
||||||
- name: Run checks
|
- name: Run checks
|
||||||
run: |
|
run: |
|
||||||
npm run format
|
npm run format
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -24,4 +24,7 @@ yarn.lock
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
# Jambonz
|
# Jambonz
|
||||||
/public/fonts
|
/public/fonts
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
cypress/videos
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
npx lint-staged
|
npx lint-staged
|
||||||
|
|
||||||
# run tests
|
# run tests
|
||||||
npm run test
|
# npm run test
|
||||||
|
|
||||||
# run build -- tsc
|
# run build -- tsc
|
||||||
# npm run build
|
# npm run build
|
||||||
|
|||||||
11
cypress.config.ts
Normal file
11
cypress.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "cypress";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
video: false,
|
||||||
|
component: {
|
||||||
|
devServer: {
|
||||||
|
framework: "react",
|
||||||
|
bundler: "vite",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
10
cypress/fixtures/accounts.json
Normal file
10
cypress/fixtures/accounts.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "default account",
|
||||||
|
"account_sid": "9351f46a-678c-43f5-b8a6-d4eb58d131af"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "custom account",
|
||||||
|
"account_sid": "04e36b64-a4e5-4221-be7e-db80da39234d"
|
||||||
|
}
|
||||||
|
]
|
||||||
10
cypress/fixtures/applications.json
Normal file
10
cypress/fixtures/applications.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "dial time",
|
||||||
|
"application_sid": "4ca2fb6a-8636-4f2e-96ff-8966c5e26f8e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hello world",
|
||||||
|
"application_sid": "7087fe50-8acb-4f3b-b820-97b573723aab"
|
||||||
|
}
|
||||||
|
]
|
||||||
39
cypress/support/commands.ts
Normal file
39
cypress/support/commands.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
// ***********************************************
|
||||||
|
// This example commands.ts shows you how to
|
||||||
|
// create various custom commands and overwrite
|
||||||
|
// existing commands.
|
||||||
|
//
|
||||||
|
// For more comprehensive examples of custom
|
||||||
|
// commands please read more here:
|
||||||
|
// https://on.cypress.io/custom-commands
|
||||||
|
// ***********************************************
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a parent command --
|
||||||
|
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a child command --
|
||||||
|
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a dual command --
|
||||||
|
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This will overwrite an existing command --
|
||||||
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
//
|
||||||
|
// declare global {
|
||||||
|
// namespace Cypress {
|
||||||
|
// interface Chainable {
|
||||||
|
// login(email: string, password: string): Chainable<void>
|
||||||
|
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||||
|
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||||
|
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
export {};
|
||||||
12
cypress/support/component-index.html
Normal file
12
cypress/support/component-index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||||
|
<title>Components App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div data-cy-root></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
cypress/support/component.ts
Normal file
41
cypress/support/component.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// ***********************************************************
|
||||||
|
// This example support/component.ts is processed and
|
||||||
|
// loaded automatically before your test files.
|
||||||
|
//
|
||||||
|
// This is a great place to put global configuration and
|
||||||
|
// behavior that modifies Cypress.
|
||||||
|
//
|
||||||
|
// You can change the location of this file or turn off
|
||||||
|
// automatically serving support files with the
|
||||||
|
// 'supportFile' configuration option.
|
||||||
|
//
|
||||||
|
// You can read more here:
|
||||||
|
// https://on.cypress.io/configuration
|
||||||
|
// ***********************************************************
|
||||||
|
|
||||||
|
// Import jambonz global styles
|
||||||
|
import "src/styles/index.scss";
|
||||||
|
|
||||||
|
// Import commands.js using ES2015 syntax:
|
||||||
|
import "./commands";
|
||||||
|
|
||||||
|
import { mount } from "cypress/react18";
|
||||||
|
|
||||||
|
// Augment the Cypress namespace to include type definitions for
|
||||||
|
// your custom command.
|
||||||
|
// Alternatively, can be defined in cypress/support/component.d.ts
|
||||||
|
// with a <reference path="./component" /> at the top of your spec.
|
||||||
|
declare global {
|
||||||
|
// Disabling, but: https://typescript-eslint.io/rules/no-namespace/...
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
namespace Cypress {
|
||||||
|
interface Chainable {
|
||||||
|
mount: typeof mount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cypress.Commands.add("mount", mount);
|
||||||
|
|
||||||
|
// Example use:
|
||||||
|
// cy.mount(<MyComponent />)
|
||||||
@@ -29,6 +29,7 @@ with [typescript](https://www.typescriptlang.org/),
|
|||||||
[prettier](https://prettier.io/), [eslint](https://eslint.org/),
|
[prettier](https://prettier.io/), [eslint](https://eslint.org/),
|
||||||
[husky](https://typicode.github.io/husky/#/)
|
[husky](https://typicode.github.io/husky/#/)
|
||||||
and [lint-staged](https://www.npmjs.com/package/lint-staged).
|
and [lint-staged](https://www.npmjs.com/package/lint-staged).
|
||||||
|
For testing we're using [cypress](https://docs.cypress.io/guides/component-testing/writing-your-first-component-test).
|
||||||
|
|
||||||
## :lock: Auth middleware
|
## :lock: Auth middleware
|
||||||
|
|
||||||
@@ -177,6 +178,17 @@ the `BEM` style we use:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## :robot: Testing
|
||||||
|
|
||||||
|
We're using [cypress](https://docs.cypress.io/guides/component-testing/writing-your-first-component-test)
|
||||||
|
for component testing. Cypress is already configured and we're actively working on backfilling complete
|
||||||
|
test coverage of the application. There are some issues open for this so you may refer to those to check
|
||||||
|
out the progress.
|
||||||
|
|
||||||
|
All new components should have tests written alongside them. For example, if you component is called
|
||||||
|
`my-component.tsx` then you would also have a `my-component.cy.tsx` test file to go with it. You can
|
||||||
|
refer to existing tests for some common patterns we use to write tests.
|
||||||
|
|
||||||
## :heart: Contributing
|
## :heart: Contributing
|
||||||
|
|
||||||
If you would like to contribute to this project please follow these simple guidelines:
|
If you would like to contribute to this project please follow these simple guidelines:
|
||||||
|
|||||||
2049
package-lock.json
generated
2049
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jambonz-webapp",
|
"name": "jambonz-webapp",
|
||||||
"description": "Simple provisioning webapp for jambonz",
|
"description": "A simple provisioning web app for jambonz",
|
||||||
"version": "v1.0.0",
|
"version": "v1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -34,7 +34,8 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext ts,tsx --max-warnings=0",
|
"lint": "eslint . --ext ts,tsx --max-warnings=0",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --check .",
|
||||||
"test": "echo 'need to setup testing'",
|
"test": "cypress run --component --headless",
|
||||||
|
"test:open": "cypress open --component",
|
||||||
"serve": "serve -s dist -l ${HTTP_PORT:-3001}",
|
"serve": "serve -s dist -l ${HTTP_PORT:-3001}",
|
||||||
"pm2": "pm2 start npm --name \"jambonz-webapp\" -- run serve",
|
"pm2": "pm2 start npm --name \"jambonz-webapp\" -- run serve",
|
||||||
"deploy": "npm i && npm run build && npm run pm2"
|
"deploy": "npm i && npm run build && npm run pm2"
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
"@typescript-eslint/parser": "^5.30.6",
|
"@typescript-eslint/parser": "^5.30.6",
|
||||||
"@vitejs/plugin-react": "^1.3.0",
|
"@vitejs/plugin-react": "^1.3.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"cypress": "^10.8.0",
|
||||||
"eslint": "^8.19.0",
|
"eslint": "^8.19.0",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.5.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.6.0",
|
"eslint-plugin-jsx-a11y": "^6.6.0",
|
||||||
|
|||||||
37
src/components/access-control.cy.tsx
Normal file
37
src/components/access-control.cy.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { MainApp } from "src/main-app";
|
||||||
|
import { AccessControl } from "./access-control";
|
||||||
|
|
||||||
|
import type { ACLProps } from "./access-control";
|
||||||
|
|
||||||
|
/** Wrapper to pass different ACLs */
|
||||||
|
const AccessControlTestWrapper = (props: Partial<ACLProps>) => {
|
||||||
|
return (
|
||||||
|
<MainApp>
|
||||||
|
<AccessControl acl={props.acl!}>
|
||||||
|
<div className="acl-div" />
|
||||||
|
</AccessControl>
|
||||||
|
</MainApp>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("<AccessControl>", () => {
|
||||||
|
it("mounts", () => {
|
||||||
|
cy.mount(<AccessControlTestWrapper acl="hasAdminAuth" />);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has admin ACL", () => {
|
||||||
|
cy.mount(<AccessControlTestWrapper acl="hasAdminAuth" />);
|
||||||
|
|
||||||
|
/** Default ACL applies admin auth -- the singleton admin user */
|
||||||
|
cy.get(".acl-div").should("exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has doesn't have teams fqdn ACL", () => {
|
||||||
|
cy.mount(<AccessControlTestWrapper acl="hasMSTeamsFqdn" />);
|
||||||
|
|
||||||
|
/** Default ACL disables MS Teams FQDN */
|
||||||
|
cy.get(".acl-div").should("not.exist");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { useSelectState } from "src/store";
|
|||||||
|
|
||||||
import type { ACL } from "src/store/types";
|
import type { ACL } from "src/store/types";
|
||||||
|
|
||||||
type ACLProps = {
|
export type ACLProps = {
|
||||||
acl: keyof ACL;
|
acl: keyof ACL;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|||||||
74
src/components/account-filter.cy.tsx
Normal file
74
src/components/account-filter.cy.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
import { sortLocaleName } from "src/utils";
|
||||||
|
|
||||||
|
import { AccountFilter } from "./account-filter";
|
||||||
|
|
||||||
|
import type { AccountFilterProps } from "./account-filter";
|
||||||
|
import type { Account } from "src/api/types";
|
||||||
|
|
||||||
|
/** Import fixture data directly so we don't use cy.fixture() ... */
|
||||||
|
import accounts from "../../cypress/fixtures/accounts.json";
|
||||||
|
|
||||||
|
/** Wrapper to perform React state setup */
|
||||||
|
const AccountFilterTestWrapper = (props: Partial<AccountFilterProps>) => {
|
||||||
|
const [account, setAccount] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccountFilter
|
||||||
|
label="Test"
|
||||||
|
accounts={accounts as Account[]}
|
||||||
|
account={[account, setAccount]}
|
||||||
|
defaultOption={props.defaultOption}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("<AccountFilter>", () => {
|
||||||
|
/** The AccountFilter uses sort with `localeCompare` */
|
||||||
|
const accountsSorted = accounts.sort(sortLocaleName);
|
||||||
|
|
||||||
|
it("mounts", () => {
|
||||||
|
cy.mount(<AccountFilterTestWrapper />);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has label text", () => {
|
||||||
|
cy.mount(<AccountFilterTestWrapper />);
|
||||||
|
|
||||||
|
/** Label text is properly set */
|
||||||
|
cy.get("label").should("have.text", "Test:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has default value", () => {
|
||||||
|
cy.mount(<AccountFilterTestWrapper />);
|
||||||
|
|
||||||
|
/** Default value is properly set to first option */
|
||||||
|
cy.get("select").should("have.value", accountsSorted[0].account_sid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates value onChange", () => {
|
||||||
|
cy.mount(<AccountFilterTestWrapper />);
|
||||||
|
|
||||||
|
/** Assert onChange value updates */
|
||||||
|
cy.get("select").select(accountsSorted[1].account_sid);
|
||||||
|
cy.get("select").should("have.value", accountsSorted[1].account_sid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manages the focused state", () => {
|
||||||
|
cy.mount(<AccountFilterTestWrapper />);
|
||||||
|
|
||||||
|
/** Test the `focused` state className (applied onFocus) */
|
||||||
|
cy.get("select").select(accountsSorted[1].account_sid);
|
||||||
|
cy.get(".account-filter").should("have.class", "focused");
|
||||||
|
cy.get("select").blur();
|
||||||
|
cy.get(".account-filter").should("not.have.class", "focused");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with default option", () => {
|
||||||
|
/** Test with the `defaultOption` prop */
|
||||||
|
cy.mount(<AccountFilterTestWrapper defaultOption />);
|
||||||
|
|
||||||
|
/** No default value is set when this prop is present */
|
||||||
|
cy.get("select").should("have.value", "");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,9 +4,9 @@ import { classNames } from "jambonz-ui";
|
|||||||
import { Icons } from "src/components/icons";
|
import { Icons } from "src/components/icons";
|
||||||
|
|
||||||
import type { Account } from "src/api/types";
|
import type { Account } from "src/api/types";
|
||||||
import { hasLength } from "src/utils";
|
import { hasLength, sortLocaleName } from "src/utils";
|
||||||
|
|
||||||
type AccountFilterProps = {
|
export type AccountFilterProps = {
|
||||||
label?: string;
|
label?: string;
|
||||||
account: [string, React.Dispatch<React.SetStateAction<string>>];
|
account: [string, React.Dispatch<React.SetStateAction<string>>];
|
||||||
accounts?: Account[];
|
accounts?: Account[];
|
||||||
@@ -54,15 +54,13 @@ export const AccountFilter = ({
|
|||||||
!accounts.length && <option value="">No accounts</option>
|
!accounts.length && <option value="">No accounts</option>
|
||||||
)}
|
)}
|
||||||
{hasLength(accounts) &&
|
{hasLength(accounts) &&
|
||||||
accounts
|
accounts.sort(sortLocaleName).map((acct) => {
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
return (
|
||||||
.map((acct) => {
|
<option key={acct.account_sid} value={acct.account_sid}>
|
||||||
return (
|
{acct.name}
|
||||||
<option key={acct.account_sid} value={acct.account_sid}>
|
</option>
|
||||||
{acct.name}
|
);
|
||||||
</option>
|
})}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
</select>
|
||||||
<span>
|
<span>
|
||||||
<Icons.ChevronUp />
|
<Icons.ChevronUp />
|
||||||
|
|||||||
87
src/components/application-filter.cy.tsx
Normal file
87
src/components/application-filter.cy.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
import { sortLocaleName } from "src/utils";
|
||||||
|
|
||||||
|
import { ApplicationFilter } from "./application-filter";
|
||||||
|
|
||||||
|
import type { ApplicationFilterProps } from "./application-filter";
|
||||||
|
import type { Application } from "src/api/types";
|
||||||
|
|
||||||
|
/** Import fixture data directly so we don't use cy.fixture() ... */
|
||||||
|
import applications from "../../cypress/fixtures/applications.json";
|
||||||
|
|
||||||
|
/** Wrapper to perform React state setup */
|
||||||
|
const ApplicationFilterTestWrapper = (
|
||||||
|
props: Partial<ApplicationFilterProps>
|
||||||
|
) => {
|
||||||
|
const [application, setApplication] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ApplicationFilter
|
||||||
|
label="Test"
|
||||||
|
applications={applications as Application[]}
|
||||||
|
application={[application, setApplication]}
|
||||||
|
defaultOption={props.defaultOption}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("<ApplicationFilter>", () => {
|
||||||
|
/** The AccountFilter uses sort with `localeCompare` */
|
||||||
|
const applicationsSorted = applications.sort(sortLocaleName);
|
||||||
|
|
||||||
|
it("mounts", () => {
|
||||||
|
cy.mount(<ApplicationFilterTestWrapper />);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has label text", () => {
|
||||||
|
cy.mount(<ApplicationFilterTestWrapper />);
|
||||||
|
|
||||||
|
/** Label text is properly set */
|
||||||
|
cy.get("label").should("have.text", "Test:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has default value", () => {
|
||||||
|
cy.mount(<ApplicationFilterTestWrapper />);
|
||||||
|
|
||||||
|
/** Default value is properly set to first option */
|
||||||
|
cy.get("select").should(
|
||||||
|
"have.value",
|
||||||
|
applicationsSorted[0].application_sid
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates value onChange", () => {
|
||||||
|
cy.mount(<ApplicationFilterTestWrapper />);
|
||||||
|
|
||||||
|
/** Assert onChange value updates */
|
||||||
|
cy.get("select").select(applicationsSorted[1].application_sid);
|
||||||
|
cy.get("select").should(
|
||||||
|
"have.value",
|
||||||
|
applicationsSorted[1].application_sid
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manages focused state", () => {
|
||||||
|
cy.mount(<ApplicationFilterTestWrapper />);
|
||||||
|
|
||||||
|
/** Test the `focused` state className (applied onFocus) */
|
||||||
|
cy.get("select").select(applicationsSorted[1].application_sid);
|
||||||
|
cy.get(".application-filter").should("have.class", "focused");
|
||||||
|
cy.get("select").blur();
|
||||||
|
cy.get(".application-filter").should("not.have.class", "focused");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders default option", () => {
|
||||||
|
/** Test with the `defaultOption` prop */
|
||||||
|
cy.mount(
|
||||||
|
<ApplicationFilterTestWrapper defaultOption="Choose Application" />
|
||||||
|
);
|
||||||
|
|
||||||
|
/** No default value is set when this prop is present */
|
||||||
|
cy.get("select").should("have.value", "");
|
||||||
|
|
||||||
|
/** Validate that our prop renders correct default option text */
|
||||||
|
cy.get("option").first().should("have.text", "Choose Application");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,10 +2,11 @@ import React, { useState } from "react";
|
|||||||
import { classNames } from "jambonz-ui";
|
import { classNames } from "jambonz-ui";
|
||||||
|
|
||||||
import { Icons } from "src/components/icons";
|
import { Icons } from "src/components/icons";
|
||||||
|
import { sortLocaleName } from "src/utils";
|
||||||
|
|
||||||
import type { Application } from "src/api/types";
|
import type { Application } from "src/api/types";
|
||||||
|
|
||||||
type ApplicationFilterProps = JSX.IntrinsicElements["select"] & {
|
export type ApplicationFilterProps = JSX.IntrinsicElements["select"] & {
|
||||||
label?: string;
|
label?: string;
|
||||||
application: [string, React.Dispatch<React.SetStateAction<string>>];
|
application: [string, React.Dispatch<React.SetStateAction<string>>];
|
||||||
applications?: Application[];
|
applications?: Application[];
|
||||||
@@ -40,15 +41,13 @@ export const ApplicationFilter = ({
|
|||||||
>
|
>
|
||||||
{defaultOption && <option value="">{defaultOption}</option>}
|
{defaultOption && <option value="">{defaultOption}</option>}
|
||||||
{applications &&
|
{applications &&
|
||||||
applications
|
applications.sort(sortLocaleName).map((app) => {
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
return (
|
||||||
.map((app) => {
|
<option key={app.application_sid} value={app.application_sid}>
|
||||||
return (
|
{app.name}
|
||||||
<option key={app.application_sid} value={app.application_sid}>
|
</option>
|
||||||
{app.name}
|
);
|
||||||
</option>
|
})}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
</select>
|
||||||
<span>
|
<span>
|
||||||
<Icons.ChevronUp />
|
<Icons.ChevronUp />
|
||||||
|
|||||||
71
src/components/select-filter.cy.tsx
Normal file
71
src/components/select-filter.cy.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
import { SelectFilter } from "./select-filter";
|
||||||
|
|
||||||
|
/** Wrapper to perform React state setup */
|
||||||
|
const SelectFilterTestWrapper = ({ onSelectSpy = () => null }) => {
|
||||||
|
const [selected, setSelected] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectFilter
|
||||||
|
id="test"
|
||||||
|
label="Test"
|
||||||
|
options={[
|
||||||
|
{ name: "Option one", value: "1" },
|
||||||
|
{ name: "Option two", value: "2" },
|
||||||
|
{ name: "Option three", value: "3" },
|
||||||
|
]}
|
||||||
|
filter={[selected, setSelected]}
|
||||||
|
handleSelect={onSelectSpy}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("<SelectFilter>", () => {
|
||||||
|
it("mounts", () => {
|
||||||
|
cy.mount(<SelectFilterTestWrapper />);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has label text", () => {
|
||||||
|
cy.mount(<SelectFilterTestWrapper />);
|
||||||
|
|
||||||
|
/** Label text is properly set */
|
||||||
|
cy.get("label").should("have.text", "Test:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has id prop", () => {
|
||||||
|
cy.mount(<SelectFilterTestWrapper />);
|
||||||
|
|
||||||
|
/** Select ID attribute is properly set */
|
||||||
|
cy.get("select").should("have.id", "test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has default value", () => {
|
||||||
|
cy.mount(<SelectFilterTestWrapper />);
|
||||||
|
|
||||||
|
/** Default value is properly set to first option */
|
||||||
|
cy.get("select").should("have.value", "1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onChange handler", () => {
|
||||||
|
/** Create a spy for the `onChange` handler */
|
||||||
|
const onSelectSpy = cy.spy().as("onSelectSpy");
|
||||||
|
|
||||||
|
cy.mount(<SelectFilterTestWrapper onSelectSpy={onSelectSpy} />);
|
||||||
|
|
||||||
|
/** Assert onChange value and custom handler is called */
|
||||||
|
cy.get("select").select("2");
|
||||||
|
cy.get("select").should("have.value", "2");
|
||||||
|
cy.get("@onSelectSpy").should("have.been.calledOnce");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manages focused state", () => {
|
||||||
|
cy.mount(<SelectFilterTestWrapper />);
|
||||||
|
|
||||||
|
/** Test the `focused` state className (applied onFocus) */
|
||||||
|
cy.get("select").select("3");
|
||||||
|
cy.get(".select-filter").should("have.class", "focused");
|
||||||
|
cy.get("select").blur();
|
||||||
|
cy.get(".select-filter").should("not.have.class", "focused");
|
||||||
|
});
|
||||||
|
});
|
||||||
18
src/main-app.tsx
Normal file
18
src/main-app.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
import { StateProvider } from "./store";
|
||||||
|
import { AuthProvider } from "./router/auth";
|
||||||
|
|
||||||
|
/** Export `MainApp` so it can be used in Cypress component tests */
|
||||||
|
export const MainApp = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<React.StrictMode>
|
||||||
|
<StateProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StateProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
src/main.tsx
24
src/main.tsx
@@ -1,27 +1,15 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
|
||||||
|
|
||||||
|
import { MainApp } from "./main-app";
|
||||||
import { Router } from "./router";
|
import { Router } from "./router";
|
||||||
import { StateProvider } from "./store";
|
|
||||||
import { AuthProvider } from "./router/auth";
|
|
||||||
|
|
||||||
import "./styles/index.scss";
|
import "./styles/index.scss";
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
return (
|
|
||||||
<React.StrictMode>
|
|
||||||
<StateProvider>
|
|
||||||
<BrowserRouter>
|
|
||||||
<AuthProvider>
|
|
||||||
<Router />
|
|
||||||
</AuthProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
</StateProvider>
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const root: Element = document.getElementById("root")!;
|
const root: Element = document.getElementById("root")!;
|
||||||
|
|
||||||
createRoot(root).render(<App />);
|
createRoot(root).render(
|
||||||
|
<MainApp>
|
||||||
|
<Router />
|
||||||
|
</MainApp>
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getUser, getServiceProviders } from "src/api";
|
import { getUser, getServiceProviders } from "src/api";
|
||||||
|
import { sortLocaleName } from "src/utils";
|
||||||
|
|
||||||
import type { State, Action } from "./types";
|
import type { State, Action } from "./types";
|
||||||
import type { ServiceProvider, User } from "src/api/types";
|
import type { ServiceProvider, User } from "src/api/types";
|
||||||
@@ -17,9 +18,7 @@ export const serviceProvidersAction = (
|
|||||||
action: Action<keyof State>
|
action: Action<keyof State>
|
||||||
) => {
|
) => {
|
||||||
// Sorts for consistent list view
|
// Sorts for consistent list view
|
||||||
action.payload = (<ServiceProvider[]>action.payload).sort((a, b) =>
|
action.payload = (<ServiceProvider[]>action.payload).sort(sortLocaleName);
|
||||||
a.name.localeCompare(b.name)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sets initial currentServiceProvider
|
// Sets initial currentServiceProvider
|
||||||
if (!state.currentServiceProvider) {
|
if (!state.currentServiceProvider) {
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ export const formatTime = (seconds: number) => {
|
|||||||
return `${minutes}m ${remainingSeconds}s`;
|
return `${minutes}m ${remainingSeconds}s`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sortLocaleName = (
|
||||||
|
a: Required<{ name: string }>,
|
||||||
|
b: Required<{ name: string }>
|
||||||
|
) => a.name.localeCompare(b.name);
|
||||||
|
|
||||||
export {
|
export {
|
||||||
withSuspense,
|
withSuspense,
|
||||||
useMobileMedia,
|
useMobileMedia,
|
||||||
|
|||||||
@@ -22,6 +22,6 @@
|
|||||||
"src": ["./src"]
|
"src": ["./src"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src", "cypress"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user