refactor(web): update to react 18 and other deps (#285)

* Started refactoring codebase for React 18.

* Migrated to react-router v6 fully

* Removed useless test setup along with relevant packages.

* Removed leftover console.log statement

* feat: use status forbidden for onboarding

* refactor(web): use react hook form on login

* fix: comply with r18 shenanigans

* chore: update packages
This commit is contained in:
stacksmash76 2022-06-10 19:31:46 +02:00 committed by GitHub
parent d065fee16b
commit 4d753b76ed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 2252 additions and 2145 deletions

View file

@ -133,7 +133,7 @@ func (h authHandler) canOnboard(w http.ResponseWriter, r *http.Request) {
if userCount > 0 { if userCount > 0 {
// send 503 service onboarding unavailable // send 503 service onboarding unavailable
http.Error(w, "Onboarding unavailable", http.StatusServiceUnavailable) http.Error(w, "Onboarding unavailable", http.StatusForbidden)
return return
} }

View file

@ -1,34 +1,34 @@
{ {
"name": "web", "name": "web",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"homepage": ".", "homepage": ".",
"dependencies": { "dependencies": {
"@fontsource/inter": "^4.5.4", "@fontsource/inter": "^4.5.10",
"@headlessui/react": "^1.2.0", "@headlessui/react": "^1.6.3",
"@heroicons/react": "^1.0.1", "@heroicons/react": "^1.0.6",
"date-fns": "^2.25.0", "@hookform/error-message": "^2.0.0",
"date-fns": "^2.28.0",
"formik": "^2.2.9", "formik": "^2.2.9",
"react": "^17.0.2", "react": "^18.1.0",
"react-cookie": "^4.1.1", "react-cookie": "^4.1.1",
"react-debounce-input": "^3.2.5", "react-debounce-input": "^3.2.5",
"react-dom": "^17.0.2", "react-dom": "^18.1.0",
"react-error-boundary": "^3.1.4", "react-error-boundary": "^3.1.4",
"react-hot-toast": "^2.1.1", "react-hook-form": "^7.31.3",
"react-multi-select-component": "4.2.5", "react-hot-toast": "^2.2.0",
"react-query": "^3.18.1", "react-multi-select-component": "^4.2.7",
"react-query": "^3.39.0",
"react-ridge-state": "4.2.2", "react-ridge-state": "4.2.2",
"react-router-dom": "^5.2.0", "react-router-dom": "^6.3.0",
"react-scripts": "^5.0.0", "react-scripts": "^5.0.1",
"react-select": "5.0.0-beta.0", "react-select": "^5.3.2",
"react-table": "^7.7.0", "react-table": "^7.8.0",
"stacktracey": "^2.1.8", "stacktracey": "^2.1.8"
"web-vitals": "^1.0.1"
}, },
"scripts": { "scripts": {
"start": "BROWSER=none react-scripts start", "start": "BROWSER=none react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject", "eject": "react-scripts eject",
"lint": "eslint src/ --ext .js,.jsx,.ts,.tsx --color", "lint": "eslint src/ --ext .js,.jsx,.ts,.tsx --color",
"lint:watch": "npm run lint -- --watch" "lint:watch": "npm run lint -- --watch"
@ -45,32 +45,24 @@
"last 1 safari version" "last 1 safari version"
] ]
}, },
"overrides": {
"@types/react": "17.0.0",
"@types/react-dom": "17.0.0"
},
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.4.0", "@tailwindcss/forms": "^0.5.2",
"@testing-library/jest-dom": "^5.11.4", "@types/node": "^17.0.35",
"@testing-library/react": "^11.1.0", "@types/react": "^18.0.9",
"@testing-library/user-event": "^12.1.10", "@types/react-dom": "^18.0.5",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "17.0.0",
"@types/react-dom": "17.0.0",
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
"@types/react-table": "^7.7.7", "@types/react-table": "^7.7.12",
"@typescript-eslint/eslint-plugin": "^5.18.0", "@typescript-eslint/eslint-plugin": "^5.26.0",
"@typescript-eslint/parser": "^5.18.0", "@typescript-eslint/parser": "^5.26.0",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.7",
"eslint": "^8.8.0", "eslint": "^8.16.0",
"eslint-plugin-import": "^2.25.4", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-react": "^7.28.0", "eslint-plugin-react": "^7.30.0",
"eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-react-hooks": "^4.5.0",
"eslint-watch": "^8.0.0", "eslint-watch": "^8.0.0",
"http-proxy-middleware": "^2.0.6", "http-proxy-middleware": "^2.0.6",
"postcss": "^8.4.6", "postcss": "^8.4.14",
"tailwindcss": "^3.0.18", "tailwindcss": "^3.0.24",
"typescript": "^4.1.2" "typescript": "^4.7.2"
} }
} }

View file

@ -1,15 +1,9 @@
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import { QueryClient, QueryClientProvider, useQueryErrorResetBoundary } from "react-query"; import { QueryClient, QueryClientProvider, useQueryErrorResetBoundary } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools"; import { ReactQueryDevtools } from "react-query/devtools";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { toast, Toaster } from "react-hot-toast"; import { toast, Toaster } from "react-hot-toast";
import Base from "./screens/Base"; import { LocalRouter } from "./domain/routes";
import { Login } from "./screens/auth/login";
import { Logout } from "./screens/auth/logout";
import { Onboarding } from "./screens/auth/onboarding";
import { baseUrl } from "./utils";
import { AuthContext, SettingsContext } from "./utils/Context"; import { AuthContext, SettingsContext } from "./utils/Context";
import { ErrorPage } from "./components/alerts"; import { ErrorPage } from "./components/alerts";
import Toast from "./components/notifications/Toast"; import Toast from "./components/notifications/Toast";
@ -44,17 +38,7 @@ export function App() {
onReset={reset} onReset={reset}
fallbackRender={ErrorPage} fallbackRender={ErrorPage}
> >
<Router basename={baseUrl()}> <LocalRouter isLoggedIn={authContext.isLoggedIn} />
<Route exact path="/logout" component={Logout} />
{authContext.isLoggedIn ? (
<Route component={Base} />
) : (
<Switch>
<Route exact path="/onboard" component={Onboarding} />
<Route component={Login} />
</Switch>
)}
</Router>
{settings.debug ? ( {settings.debug ? (
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
) : null} ) : null}

View file

@ -10,7 +10,7 @@ const DEBUG: FC<DebugProps> = ({ values }) => {
} }
return ( return (
<div className="w-full p-2 flex flex-col mt-12 mb-12 bg-gray-100 dark:bg-gray-900"> <div className="w-full p-2 flex flex-col mt-6 bg-gray-100 dark:bg-gray-900">
<pre className="dark:text-gray-400">{JSON.stringify(values, null, 2)}</pre> <pre className="dark:text-gray-400">{JSON.stringify(values, null, 2)}</pre>
</div> </div>
); );

View file

@ -6,11 +6,13 @@ interface ErrorFieldProps {
} }
const ErrorField = ({ name, classNames }: ErrorFieldProps) => ( const ErrorField = ({ name, classNames }: ErrorFieldProps) => (
<div>
<Field name={name} subscribe={{ touched: true, error: true }}> <Field name={name} subscribe={{ touched: true, error: true }}>
{({ meta: { touched, error } }: FieldProps) => {({ meta: { touched, error } }: FieldProps) =>
touched && error ? <span className={classNames}>{error}</span> : null touched && error ? <span className={classNames}>{error}</span> : null
} }
</Field> </Field>
</div>
); );
interface CheckboxFieldProps { interface CheckboxFieldProps {

View file

@ -161,7 +161,6 @@ export function DownloadClientSelect({
? clients.find((c) => c.id === field.value)?.name ? clients.find((c) => c.id === field.value)?.name
: "Choose a client"} : "Choose a client"}
</span> </span>
{/*<span className="block truncate">Choose a client</span>*/}
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> <span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<SelectorIcon <SelectorIcon
className="h-5 w-5 text-gray-400 dark:text-gray-300" className="h-5 w-5 text-gray-400 dark:text-gray-300"

View file

@ -0,0 +1,200 @@
import React, {
FC,
forwardRef,
ReactNode
} from "react";
import {
FieldError,
UseFormRegister,
Path,
RegisterOptions, DeepMap
} from "react-hook-form";
import { classNames, get } from "../../utils";
import { useToggle } from "../../hooks/hooks";
import { EyeIcon, EyeOffIcon } from "@heroicons/react/solid";
import { ErrorMessage } from "@hookform/error-message";
export type FormErrorMessageProps = {
className?: string;
children: ReactNode;
};
export const FormErrorMessage: FC<FormErrorMessageProps> = ({
children,
className
}) => (
<p
className={classNames(
"mt-1 text-sm text-left block text-red-600",
className ?? ""
)}
>
{children}
</p>
);
export type InputType = "text" | "email" | "password";
export type InputAutoComplete = "username" | "current-password";
export type InputColumnWidth = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
export type InputProps = {
id: string;
name: string;
label: string;
type?: InputType;
className?: string;
placeholder?: string;
autoComplete?: InputAutoComplete;
isHidden?: boolean;
columnWidth?: InputColumnWidth;
};
// Using maps so that the full Tailwind classes can be seen for purging
// see https://tailwindcss.com/docs/optimizing-for-production#writing-purgeable-html
// const sizeMap: { [key in InputSize]: string } = {
// medium: "p-3 text-base",
// large: "p-4 text-base"
// };
export const Input: FC<InputProps> = forwardRef<HTMLInputElement, InputProps>(
(
{
id,
name,
label,
type ,
className = "",
placeholder,
autoComplete,
...props
},
ref
) => {
return (
<input
id={id}
ref={ref}
name={name}
type={type}
aria-label={label}
placeholder={placeholder}
className={className}
autoComplete={autoComplete}
{...props}
/>
);
}
);
export type FormInputProps<TFormValues> = {
name: Path<TFormValues>;
rules?: RegisterOptions;
register?: UseFormRegister<TFormValues>;
errors?: Partial<DeepMap<TFormValues, FieldError>>;
} & Omit<InputProps, "name">;
export const TextInput = <TFormValues extends Record<string, unknown>>({
name,
register,
rules,
errors,
isHidden,
columnWidth,
...props
}: FormInputProps<TFormValues>): JSX.Element => {
// If the name is in a FieldArray, it will be 'fields.index.fieldName' and errors[name] won't return anything, so we are using lodash get
const errorMessages = get(errors, name);
const hasError = !!(errors && errorMessages);
return (
<div
className={classNames(
isHidden ? "hidden" : "",
columnWidth ? `col-span-${columnWidth}` : "col-span-12"
)}
>
{props.label && (
<label htmlFor={name} className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
{props.label}
</label>
)}
<div>
<Input
name={name}
aria-invalid={hasError}
className={classNames(
"mt-2 block w-full dark:bg-gray-800 dark:text-gray-100 rounded-md",
hasError ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700"
)}
{...props}
{...(register && register(name, rules))}
/>
<ErrorMessage
errors={errors}
name={name as any}
render={({ message }) => (
<FormErrorMessage>{message}</FormErrorMessage>
)}
/>
</div>
</div>
);
};
export const PasswordInput = <TFormValues extends Record<string, unknown>>({
name,
register,
rules,
errors,
isHidden,
columnWidth,
...props
}: FormInputProps<TFormValues>): JSX.Element => {
const [isVisible, toggleVisibility] = useToggle(false);
// If the name is in a FieldArray, it will be 'fields.index.fieldName' and errors[name] won't return anything, so we are using lodash get
const errorMessages = get(errors, name);
const hasError = !!(errors && errorMessages);
return (
<div
className={classNames(
isHidden ? "hidden" : "",
columnWidth ? `col-span-${columnWidth}` : "col-span-12"
)}
>
{props.label && (
<label htmlFor={name} className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
{props.label}
</label>
)}
<div>
<div className="sm:col-span-2 relative">
<Input
name={name}
aria-invalid={hasError}
type={isVisible ? "text" : "password"}
className={classNames(
"mt-2 block w-full dark:bg-gray-800 dark:text-gray-100 rounded-md",
hasError ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700"
)}
{...props}
{...(register && register(name, rules))}
/>
<div className="absolute inset-y-0 right-0 px-3 flex items-center" onClick={toggleVisibility}>
{!isVisible ? <EyeIcon className="h-5 w-5 text-gray-400 hover:text-gray-500" aria-hidden="true" /> : <EyeOffIcon className="h-5 w-5 text-gray-400 hover:text-gray-500" aria-hidden="true" />}
</div>
</div>
<ErrorMessage
errors={errors}
name={name as any}
render={({ message }) => (
<FormErrorMessage>{message}</FormErrorMessage>
)}
/>
</div>
</div>
);
};

55
web/src/domain/routes.tsx Normal file
View file

@ -0,0 +1,55 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Login } from "../screens/auth/login";
import { Logout } from "../screens/auth/logout";
import { Onboarding } from "../screens/auth/onboarding";
import Base from "../screens/Base";
import { Dashboard } from "../screens/dashboard";
import { FilterDetails, Filters } from "../screens/filters";
import { Logs } from "../screens/Logs";
import { Releases } from "../screens/releases";
import Settings from "../screens/Settings";
import ApplicationSettings from "../screens/settings/Application";
import DownloadClientSettings from "../screens/settings/DownloadClient";
import FeedSettings from "../screens/settings/Feed";
import IndexerSettings from "../screens/settings/Indexer";
import { IrcSettings } from "../screens/settings/Irc";
import NotificationSettings from "../screens/settings/Notifications";
import { RegexPlayground } from "../screens/settings/RegexPlayground";
import ReleaseSettings from "../screens/settings/Releases";
import { baseUrl } from "../utils";
export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => (
<BrowserRouter basename={baseUrl()}>
{isLoggedIn ? (
<Routes>
<Route path="/logout" element={<Logout />} />
<Route element={<Base />}>
<Route index element={<Dashboard />} />
<Route path="logs" element={<Logs />} />
<Route path="releases" element={<Releases />} />
<Route path="filters">
<Route index element={<Filters />} />
<Route path=":filterId/*" element={<FilterDetails />} />
</Route>
<Route path="settings" element={<Settings />}>
<Route index element={<ApplicationSettings />} />
<Route path="indexers" element={<IndexerSettings />} />
<Route path="feeds" element={<FeedSettings />} />
<Route path="irc" element={<IrcSettings />} />
<Route path="clients" element={<DownloadClientSettings />} />
<Route path="notifications" element={<NotificationSettings />} />
<Route path="releases" element={<ReleaseSettings />} />
<Route path="regex-playground" element={<RegexPlayground />} />
</Route>
</Route>
</Routes>
) : (
<Routes>
<Route path="/onboard" element={<Onboarding />} />
<Route path="*" element={<Login />} />
</Routes>
)}
</BrowserRouter>
);

View file

@ -1,5 +1,5 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import ReactDOM from "react-dom"; import { createRoot } from "react-dom/client";
import "@fontsource/inter/variable.css"; import "@fontsource/inter/variable.css";
import "./index.css"; import "./index.css";
@ -16,9 +16,10 @@ window.APP = window.APP || {};
// Initializes auth and theme contexts // Initializes auth and theme contexts
InitializeGlobalContext(); InitializeGlobalContext();
ReactDOM.render( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const root = createRoot(document.getElementById("root")!);
root.render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>
document.getElementById("root")
); );

View file

@ -1,15 +0,0 @@
import type { ReportHandler } from "web-vitals";
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View file

@ -1,16 +1,9 @@
import { Fragment } from "react"; import { Fragment } from "react";
import type { match } from "react-router-dom"; import { Link, NavLink, Outlet } from "react-router-dom";
import { Link, NavLink, Route, Switch } from "react-router-dom";
import { Disclosure, Menu, Transition } from "@headlessui/react"; import { Disclosure, Menu, Transition } from "@headlessui/react";
import { ExternalLinkIcon } from "@heroicons/react/solid"; import { ExternalLinkIcon } from "@heroicons/react/solid";
import { ChevronDownIcon, MenuIcon, XIcon } from "@heroicons/react/outline"; import { ChevronDownIcon, MenuIcon, XIcon } from "@heroicons/react/outline";
import Settings from "./Settings";
import { Logs } from "./Logs";
import { Releases } from "./releases";
import { Dashboard } from "./dashboard";
import { FilterDetails, Filters } from "./filters";
import { AuthContext } from "../utils/Context"; import { AuthContext } from "../utils/Context";
import logo from "../logo.png"; import logo from "../logo.png";
@ -24,23 +17,6 @@ function classNames(...classes: string[]) {
return classes.filter(Boolean).join(" "); return classes.filter(Boolean).join(" ");
} }
const isActiveMatcher = (
match: match | null,
location: { pathname: string },
item: NavItem
) => {
if (!match)
return false;
if (match?.url === "/" && item.path === "/" && location.pathname === "/")
return true;
if (match.url === "/")
return false;
return true;
};
export default function Base() { export default function Base() {
const authContext = AuthContext.useValue(); const authContext = AuthContext.useValue();
const nav: Array<NavItem> = [ const nav: Array<NavItem> = [
@ -81,13 +57,11 @@ export default function Base() {
<NavLink <NavLink
key={item.name + itemIdx} key={item.name + itemIdx}
to={item.path} to={item.path}
strict className={({ isActive }) => classNames(
className={classNames( "hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium",
"text-gray-600 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium", "transition-colors duration-200",
"transition-colors duration-200" isActive ? "text-black dark:text-gray-50 font-bold" : "text-gray-600 dark:text-gray-500"
)} )}
activeClassName="text-black dark:text-gray-50 !font-bold"
isActive={(match, location) => isActiveMatcher(match, location, item)}
> >
{item.name} {item.name}
</NavLink> </NavLink>
@ -199,10 +173,11 @@ export default function Base() {
<NavLink <NavLink
key={item.path} key={item.path}
to={item.path} to={item.path}
strict className={({ isActive }) => classNames(
className="dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base font-medium" // TODO: Double check whether this is correct
activeClassName="font-bold bg-gray-300 text-black" "dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base",
isActive={(match, location) => isActiveMatcher(match, location, item)} isActive ? "font-bold bg-gray-300 text-black" : "font-medium"
)}
> >
{item.name} {item.name}
</NavLink> </NavLink>
@ -219,32 +194,7 @@ export default function Base() {
</> </>
)} )}
</Disclosure> </Disclosure>
<Outlet />
<Switch>
<Route path="/logs">
<Logs/>
</Route>
<Route path="/settings">
<Settings/>
</Route>
<Route path="/releases">
<Releases/>
</Route>
<Route exact={true} path="/filters">
<Filters/>
</Route>
<Route path="/filters/:filterId">
<FilterDetails/>
</Route>
<Route exact path="/">
<Dashboard/>
</Route>
</Switch>
</div> </div>
); );
} }

View file

@ -1,56 +1,52 @@
import { BellIcon, ChatAlt2Icon, CogIcon, CollectionIcon, DownloadIcon, KeyIcon, RssIcon } from "@heroicons/react/outline"; import { NavLink, Outlet, useLocation } from "react-router-dom";
import { NavLink, Route, Switch as RouteSwitch, useLocation, useRouteMatch } from "react-router-dom"; import {
BellIcon,
ChatAlt2Icon,
CogIcon,
CollectionIcon,
DownloadIcon,
KeyIcon,
RssIcon
} from "@heroicons/react/outline";
import { classNames } from "../utils"; import { classNames } from "../utils";
import IndexerSettings from "./settings/Indexer";
import { IrcSettings } from "./settings/Irc";
import ApplicationSettings from "./settings/Application";
import DownloadClientSettings from "./settings/DownloadClient";
import { RegexPlayground } from "./settings/RegexPlayground";
import ReleaseSettings from "./settings/Releases";
import NotificationSettings from "./settings/Notifications";
import FeedSettings from "./settings/Feed";
interface NavTabType { interface NavTabType {
name: string; name: string;
href: string; href: string;
icon: typeof CogIcon; icon: typeof CogIcon;
current: boolean;
} }
const subNavigation: NavTabType[] = [ const subNavigation: NavTabType[] = [
{ name: "Application", href: "", icon: CogIcon, current: true }, { name: "Application", href: "", icon: CogIcon },
{ name: "Indexers", href: "indexers", icon: KeyIcon, current: false }, { name: "Indexers", href: "indexers", icon: KeyIcon },
{ name: "IRC", href: "irc", icon: ChatAlt2Icon, current: false }, { name: "IRC", href: "irc", icon: ChatAlt2Icon },
{ name: "Feeds", href: "feeds", icon: RssIcon, current: false }, { name: "Feeds", href: "feeds", icon: RssIcon },
{ name: "Clients", href: "clients", icon: DownloadIcon, current: false }, { name: "Clients", href: "clients", icon: DownloadIcon },
{ name: "Notifications", href: "notifications", icon: BellIcon, current: false }, { name: "Notifications", href: "notifications", icon: BellIcon },
{ name: "Releases", href: "releases", icon: CollectionIcon, current: false } { name: "Releases", href: "releases", icon: CollectionIcon }
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false} // {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
// {name: 'Rules', href: 'rules', icon: ClipboardCheckIcon, current: false}, // {name: 'Rules', href: 'rules', icon: ClipboardCheckIcon, current: false},
]; ];
interface NavLinkProps { interface NavLinkProps {
item: NavTabType; item: NavTabType;
url: string;
} }
function SubNavLink({ item, url }: NavLinkProps) { function SubNavLink({ item }: NavLinkProps) {
const location = useLocation(); const { pathname } = useLocation();
const { pathname } = location;
const splitLocation = pathname.split("/"); const splitLocation = pathname.split("/");
// we need to clean the / if it's a base root path // we need to clean the / if it's a base root path
const too = item.href ? `${url}/${item.href}` : url;
return ( return (
<NavLink <NavLink
key={item.name} key={item.name}
to={too} to={item.href}
exact={true} end
activeClassName="bg-teal-50 dark:bg-gray-700 border-teal-500 dark:border-blue-500 text-teal-700 dark:text-white hover:bg-teal-50 dark:hover:bg-gray-500 hover:text-teal-700 dark:hover:text-gray-200" className={({ isActive }) => classNames(
className={classNames( "border-transparent text-gray-900 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-300 group border-l-4 px-3 py-2 flex items-center text-sm font-medium",
"border-transparent text-gray-900 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-300 group border-l-4 px-3 py-2 flex items-center text-sm font-medium" isActive ?
"bg-teal-50 dark:bg-gray-700 border-teal-500 dark:border-blue-500 text-teal-700 dark:text-white hover:bg-teal-50 dark:hover:bg-gray-500 hover:text-teal-700 dark:hover:text-gray-200" : ""
)} )}
aria-current={splitLocation[2] === item.href ? "page" : undefined} aria-current={splitLocation[2] === item.href ? "page" : undefined}
> >
@ -65,15 +61,14 @@ function SubNavLink({ item, url }: NavLinkProps) {
interface SidebarNavProps { interface SidebarNavProps {
subNavigation: NavTabType[]; subNavigation: NavTabType[];
url: string;
} }
function SidebarNav({ subNavigation, url }: SidebarNavProps) { function SidebarNav({ subNavigation }: SidebarNavProps) {
return ( return (
<aside className="py-2 lg:col-span-3"> <aside className="py-2 lg:col-span-3">
<nav className="space-y-1"> <nav className="space-y-1">
{subNavigation.map((item) => ( {subNavigation.map((item) => (
<SubNavLink item={item} url={url} key={item.href}/> <SubNavLink item={item} key={item.href}/>
))} ))}
</nav> </nav>
</aside> </aside>
@ -81,7 +76,6 @@ function SidebarNav({ subNavigation, url }: SidebarNavProps) {
} }
export default function Settings() { export default function Settings() {
const { url } = useRouteMatch();
return ( return (
<main> <main>
<header className="py-10"> <header className="py-10">
@ -93,41 +87,8 @@ export default function Settings() {
<div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8"> <div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg">
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x"> <div className="divide-y divide-gray-200 dark:divide-gray-700 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
<SidebarNav url={url} subNavigation={subNavigation}/> <SidebarNav subNavigation={subNavigation}/>
<Outlet />
<RouteSwitch>
<Route exact path={url}>
<ApplicationSettings/>
</Route>
<Route path={`${url}/indexers`}>
<IndexerSettings/>
</Route>
<Route path={`${url}/feeds`}>
<FeedSettings/>
</Route>
<Route path={`${url}/irc`}>
<IrcSettings/>
</Route>
<Route path={`${url}/clients`}>
<DownloadClientSettings/>
</Route>
<Route path={`${url}/notifications`}>
<NotificationSettings />
</Route>
<Route path={`${url}/releases`}>
<ReleaseSettings/>
</Route>
<Route path={`${url}/regex-playground`}>
<RegexPlayground />
</Route>
</RouteSwitch>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,44 +1,48 @@
import { useHistory } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useMutation } from "react-query"; import { useMutation } from "react-query";
import { Form, Formik } from "formik";
import { APIClient } from "../../api/APIClient"; import { APIClient } from "../../api/APIClient";
import { TextField, PasswordField } from "../../components/inputs";
import logo from "../../logo.png"; import logo from "../../logo.png";
import { AuthContext } from "../../utils/Context"; import { AuthContext } from "../../utils/Context";
import { useEffect } from "react"; import { useEffect } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { PasswordInput, TextInput } from "../../components/inputs/text";
interface LoginData { export type LoginFormFields = {
username: string; username: string;
password: string; password: string;
} };
export const Login = () => { export const Login = () => {
const history = useHistory(); const { handleSubmit, register, formState: { errors } } = useForm<LoginFormFields>({
defaultValues: { username: "", password: "" },
mode: "onBlur"
});
const navigate = useNavigate();
const [, setAuthContext] = AuthContext.use(); const [, setAuthContext] = AuthContext.use();
useEffect(() => { useEffect(() => {
// Check if onboarding is available for this instance // Check if onboarding is available for this instance
// and redirect if needed // and redirect if needed
APIClient.auth.canOnboard() APIClient.auth.canOnboard()
.then(() => history.push("/onboard")); .then(() => navigate("/onboard"));
}, [history]); }, [history]);
const mutation = useMutation( const loginMutation = useMutation(
(data: LoginData) => APIClient.auth.login(data.username, data.password), (data: LoginFormFields) => APIClient.auth.login(data.username, data.password),
{ {
onSuccess: (_, variables: LoginData) => { onSuccess: (_, variables: LoginFormFields) => {
setAuthContext({ setAuthContext({
username: variables.username, username: variables.username,
isLoggedIn: true isLoggedIn: true
}); });
history.push("/"); navigate("/");
} }
} }
); );
const handleSubmit = (data: LoginData) => mutation.mutate(data); const onSubmit: SubmitHandler<LoginFormFields> = (data: LoginFormFields) => loginMutation.mutate(data);
return ( return (
<div className="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8"> <div className="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8">
@ -52,15 +56,29 @@ export const Login = () => {
<div className="sm:mx-auto sm:w-full sm:max-w-md shadow-lg"> <div className="sm:mx-auto sm:w-full sm:max-w-md shadow-lg">
<div className="bg-white dark:bg-gray-800 py-8 px-4 sm:rounded-lg sm:px-10"> <div className="bg-white dark:bg-gray-800 py-8 px-4 sm:rounded-lg sm:px-10">
<Formik <form onSubmit={handleSubmit(onSubmit)}>
initialValues={{ username: "", password: "" }}
onSubmit={handleSubmit}
>
<Form>
<div className="space-y-6"> <div className="space-y-6">
<TextField name="username" label="Username" columns={6} autoComplete="username" /> <TextInput<LoginFormFields>
<PasswordField name="password" label="Password" columns={6} autoComplete="current-password" /> name="username"
id="username"
label="username"
type="text"
register={register}
rules={{ required: "Username is required" }}
errors={errors}
autoComplete="username"
/>
<PasswordInput<LoginFormFields>
name="password"
id="password"
label="password"
register={register}
rules={{ required: "Password is required" }}
errors={errors}
autoComplete="current-password"
/>
</div> </div>
<div className="mt-6"> <div className="mt-6">
<button <button
type="submit" type="submit"
@ -69,8 +87,8 @@ export const Login = () => {
Sign in Sign in
</button> </button>
</div> </div>
</Form> </form>
</Formik>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,12 +1,12 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useCookies } from "react-cookie"; import { useCookies } from "react-cookie";
import { useHistory } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { APIClient } from "../../api/APIClient"; import { APIClient } from "../../api/APIClient";
import { AuthContext } from "../../utils/Context"; import { AuthContext } from "../../utils/Context";
export const Logout = () => { export const Logout = () => {
const history = useHistory(); const navigate = useNavigate();
const [, setAuthContext] = AuthContext.use(); const [, setAuthContext] = AuthContext.use();
const [,, removeCookie] = useCookies(["user_session"]); const [,, removeCookie] = useCookies(["user_session"]);
@ -15,10 +15,10 @@ export const Logout = () => {
() => { () => {
APIClient.auth.logout() APIClient.auth.logout()
.then(() => { .then(() => {
setAuthContext({ username: "", isLoggedIn: false });
removeCookie("user_session"); removeCookie("user_session");
setAuthContext({ username: "", isLoggedIn: false });
history.push("/login"); navigate("/login");
}); });
}, },
[history, removeCookie, setAuthContext] [history, removeCookie, setAuthContext]

View file

@ -1,6 +1,6 @@
import { Form, Formik } from "formik"; import { Form, Formik } from "formik";
import { useMutation } from "react-query"; import { useMutation } from "react-query";
import { useHistory } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { APIClient } from "../../api/APIClient"; import { APIClient } from "../../api/APIClient";
import { TextField, PasswordField } from "../../components/inputs"; import { TextField, PasswordField } from "../../components/inputs";
@ -30,15 +30,11 @@ export const Onboarding = () => {
return obj; return obj;
}; };
const history = useHistory(); const navigate = useNavigate();
const mutation = useMutation( const mutation = useMutation(
(data: InputValues) => APIClient.auth.onboard(data.username, data.password1), (data: InputValues) => APIClient.auth.onboard(data.username, data.password1),
{ { onSuccess: () => navigate("/login") }
onSuccess: () => {
history.push("/login");
}
}
); );
return ( return (

View file

@ -13,6 +13,7 @@ import { EmptyListState } from "../../components/emptystates";
import * as Icons from "../../components/Icons"; import * as Icons from "../../components/Icons";
import * as DataTable from "../../components/data-table"; import * as DataTable from "../../components/data-table";
import { Fragment } from "react";
// This is a custom filter UI for selecting // This is a custom filter UI for selecting
// a unique option from a list // a unique option from a list
@ -32,7 +33,7 @@ function SelectColumnFilter({
// Render a multi-select box // Render a multi-select box
return ( return (
<label className="flex items-baseline gap-x-2"> <label className="flex items-baseline gap-x-2">
<span className="text-gray-700">{render("Header")}: </span> <span className="text-gray-700"><>{render("Header")}:</></span>
<select <select
className="border-gray-300 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" className="border-gray-300 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
name={id} name={id}
@ -99,7 +100,7 @@ function Table({ columns, data }: TableProps) {
{...columnRest} {...columnRest}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{column.render("Header")} <>{column.render("Header")}</>
{/* Add a sort direction indicator */} {/* Add a sort direction indicator */}
<span> <span>
{column.isSorted ? ( {column.isSorted ? (
@ -138,7 +139,7 @@ function Table({ columns, data }: TableProps) {
role="cell" role="cell"
{...cellRowRest} {...cellRowRest}
> >
{cell.render("Cell")} <>{cell.render("Cell")}</>
</td> </td>
); );
})} })}

View file

@ -3,11 +3,10 @@ import { useMutation, useQuery } from "react-query";
import { import {
NavLink, NavLink,
Route, Route,
Switch as RouteSwitch, Routes,
useHistory,
useLocation, useLocation,
useParams, useNavigate,
useRouteMatch useParams
} from "react-router-dom"; } from "react-router-dom";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Field, FieldArray, FieldProps, Form, Formik, FormikValues } from "formik"; import { Field, FieldArray, FieldProps, Form, Formik, FormikValues } from "formik";
@ -55,24 +54,21 @@ import { EmptyListState } from "../../components/emptystates";
interface tabType { interface tabType {
name: string; name: string;
href: string; href: string;
current: boolean;
} }
const tabs: tabType[] = [ const tabs: tabType[] = [
{ name: "General", href: "", current: true }, { name: "General", href: "" },
{ name: "Movies and TV", href: "movies-tv", current: false }, { name: "Movies and TV", href: "movies-tv" },
{ name: "Music", href: "music", current: false }, { name: "Music", href: "music" },
// { name: 'P2P', href: 'p2p', current: false }, { name: "Advanced", href: "advanced" },
{ name: "Advanced", href: "advanced", current: false }, { name: "Actions", href: "actions" }
{ name: "Actions", href: "actions", current: false }
]; ];
export interface NavLinkProps { export interface NavLinkProps {
item: tabType; item: tabType;
url: string;
} }
function TabNavLink({ item, url }: NavLinkProps) { function TabNavLink({ item }: NavLinkProps) {
const location = useLocation(); const location = useLocation();
const splitLocation = location.pathname.split("/"); const splitLocation = location.pathname.split("/");
@ -80,11 +76,11 @@ function TabNavLink({ item, url }: NavLinkProps) {
return ( return (
<NavLink <NavLink
key={item.name} key={item.name}
to={item.href ? `${url}/${item.href}` : url} to={item.href}
exact end
activeClassName="border-purple-600 dark:border-blue-500 text-purple-600 dark:text-white" className={({ isActive }) => classNames(
className={classNames( "text-gray-500 hover:text-purple-600 dark:hover:text-white hover:border-purple-600 dark:hover:border-blue-500 whitespace-nowrap py-4 px-1 font-medium text-sm",
"border-transparent text-gray-500 hover:text-purple-600 dark:hover:text-white hover:border-purple-600 dark:hover:border-blue-500 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" isActive ? "border-b-2 border-purple-600 dark:border-blue-500 text-purple-600 dark:text-white" : ""
)} )}
aria-current={splitLocation[2] === item.href ? "page" : undefined} aria-current={splitLocation[2] === item.href ? "page" : undefined}
> >
@ -147,17 +143,16 @@ const FormButtonsGroup = ({ values, deleteAction, reset }: FormButtonsGroupProps
}; };
export default function FilterDetails() { export default function FilterDetails() {
const history = useHistory(); const navigate = useNavigate();
const { url } = useRouteMatch();
const { filterId } = useParams<{ filterId: string }>(); const { filterId } = useParams<{ filterId: string }>();
const { isLoading, data: filter } = useQuery( const { isLoading, data: filter } = useQuery(
["filters", filterId], ["filters", filterId],
() => APIClient.filters.getByID(parseInt(filterId)), () => APIClient.filters.getByID(parseInt(filterId ?? "0")),
{ {
retry: false, retry: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
onError: () => history.push("./") onError: () => navigate("./")
} }
); );
@ -184,7 +179,7 @@ export default function FilterDetails() {
queryClient.invalidateQueries(["filters"]); queryClient.invalidateQueries(["filters"]);
// redirect // redirect
history.push("/filters"); navigate("/filters");
} }
}); });
@ -218,7 +213,7 @@ export default function FilterDetails() {
<header className="py-10"> <header className="py-10">
<div className="max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center"> <div className="max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center">
<h1 className="text-3xl font-bold text-black dark:text-white"> <h1 className="text-3xl font-bold text-black dark:text-white">
<NavLink to="/filters" exact> <NavLink to="/filters">
Filters Filters
</NavLink> </NavLink>
</h1> </h1>
@ -229,17 +224,14 @@ export default function FilterDetails() {
<div className="max-w-screen-xl mx-auto pb-12 px-4 sm:px-6 lg:px-8"> <div className="max-w-screen-xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="relative mx-auto md:px-6 xl:px-4"> <div className="relative mx-auto md:px-6 xl:px-4">
<div className="px-4 sm:px-6 md:px-0"> <div className="pt-1 pb-6 block overflow-auto">
<div className="pt-1 pb-6">
<div className="block overflow-auto">
<div className="border-b border-gray-200 dark:border-gray-700"> <div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-6 sm:space-x-8"> <nav className="-mb-px flex space-x-6 sm:space-x-8">
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabNavLink item={tab} url={url} key={tab.href} /> <TabNavLink item={tab} key={tab.href} />
))} ))}
</nav> </nav>
</div> </div>
</div>
<Formik <Formik
initialValues={{ initialValues={{
@ -295,36 +287,19 @@ export default function FilterDetails() {
> >
{({ values, dirty, resetForm }) => ( {({ values, dirty, resetForm }) => (
<Form> <Form>
<RouteSwitch> <Routes>
<Route exact path={url}> <Route index element={<General />} />
<General /> <Route path="movies-tv" element={<MoviesTv />} />
</Route> <Route path="music" element={<Music />} />
<Route path="advanced" element={<Advanced />} />
<Route path={`${url}/movies-tv`}> <Route path="actions" element={<FilterActions filter={filter} values={values} />}
<MoviesTv /> />
</Route> </Routes>
<Route path={`${url}/music`}>
<Music />
</Route>
<Route path={`${url}/advanced`}>
<Advanced />
</Route>
<Route path={`${url}/actions`}>
<FilterActions filter={filter} values={values} />
</Route>
</RouteSwitch>
<FormButtonsGroup values={values} deleteAction={deleteAction} dirty={dirty} reset={resetForm} /> <FormButtonsGroup values={values} deleteAction={deleteAction} dirty={dirty} reset={resetForm} />
<DEBUG values={values} /> <DEBUG values={values} />
</Form> </Form>
)} )}
</Formik> </Formik>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -333,7 +308,7 @@ export default function FilterDetails() {
); );
} }
function General() { export function General() {
const { isLoading, data: indexers } = useQuery( const { isLoading, data: indexers } = useQuery(
["filters", "indexer_list"], ["filters", "indexer_list"],
APIClient.indexers.getOptions, APIClient.indexers.getOptions,
@ -380,7 +355,7 @@ function General() {
); );
} }
function MoviesTv() { export function MoviesTv() {
return ( return (
<div> <div>
<div className="mt-6 grid grid-cols-12 gap-6"> <div className="mt-6 grid grid-cols-12 gap-6">
@ -424,7 +399,7 @@ function MoviesTv() {
); );
} }
function Music() { export function Music() {
return ( return (
<div> <div>
<div className="mt-6 grid grid-cols-12 gap-6"> <div className="mt-6 grid grid-cols-12 gap-6">
@ -476,7 +451,7 @@ function Music() {
); );
} }
function Advanced() { export function Advanced() {
return ( return (
<div> <div>
<CollapsableSection title="Releases" subtitle="Match only certain release names and/or ignore other release names"> <CollapsableSection title="Releases" subtitle="Match only certain release names and/or ignore other release names">
@ -559,7 +534,7 @@ interface FilterActionsProps {
values: FormikValues; values: FormikValues;
} }
function FilterActions({ filter, values }: FilterActionsProps) { export function FilterActions({ filter, values }: FilterActionsProps) {
const { data } = useQuery( const { data } = useQuery(
["filters", "download_clients"], ["filters", "download_clients"],
APIClient.download_clients.getAll, APIClient.download_clients.getAll,

View file

@ -172,7 +172,7 @@ const FilterItemDropdown = ({
<Menu.Item> <Menu.Item>
{({ active }) => ( {({ active }) => (
<Link <Link
to={`filters/${filter.id.toString()}`} to={filter.id.toString()}
className={classNames( className={classNames(
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300", active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm" "font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
@ -319,7 +319,7 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
</td> </td>
<td className="px-6 w-full whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100"> <td className="px-6 w-full whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
<Link <Link
to={`filters/${filter.id.toString()}`} to={filter.id.toString()}
className="hover:text-black dark:hover:text-gray-300 w-full py-4 flex" className="hover:text-black dark:hover:text-gray-300 w-full py-4 flex"
> >
{filter.name} {filter.name}

View file

@ -186,7 +186,7 @@ export const ReleaseTable = () => {
headerGroup.headers.map((column) => ( headerGroup.headers.map((column) => (
column.Filter ? ( column.Filter ? (
<div className="mt-2 sm:mt-0" key={column.id}> <div className="mt-2 sm:mt-0" key={column.id}>
{column.render("Filter")} <>{column.render("Filter")}</>
</div> </div>
) : null ) : null
)) ))
@ -211,7 +211,7 @@ export const ReleaseTable = () => {
{...columnRest} {...columnRest}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{column.render("Header")} <>{column.render("Header")}</>
{/* Add a sort direction indicator */} {/* Add a sort direction indicator */}
<span> <span>
{column.isSorted ? ( {column.isSorted ? (
@ -251,7 +251,7 @@ export const ReleaseTable = () => {
role="cell" role="cell"
{...cellRowRest} {...cellRowRest}
> >
{cell.render("Cell")} <>{cell.render("Cell")}</>
</td> </td>
); );
})} })}

View file

@ -103,7 +103,7 @@ const ListItem = ({ idx, network }: ListItemProps) => {
<div className="col-span-4 flex justify-between items-center sm:px-6 text-sm text-gray-500 dark:text-gray-400 cursor-pointer" onClick={toggleEdit}>{network.server}:{network.port} {network.tls && <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-300 text-green-800 dark:text-green-900">TLS</span>}</div> <div className="col-span-4 flex justify-between items-center sm:px-6 text-sm text-gray-500 dark:text-gray-400 cursor-pointer" onClick={toggleEdit}>{network.server}:{network.port} {network.tls && <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-300 text-green-800 dark:text-green-900">TLS</span>}</div>
{network.nickserv && network.nickserv.account ? ( {network.nickserv && network.nickserv.account ? (
<div className="col-span-4 items-center sm:px-6 text-sm text-gray-500 dark:text-gray-400 cursor-pointer" onClick={toggleEdit}>{network.nickserv.account}</div> <div className="col-span-4 items-center sm:px-6 text-sm text-gray-500 dark:text-gray-400 cursor-pointer" onClick={toggleEdit}>{network.nickserv.account}</div>
) : null} ) : <div className="col-span-4" />}
<div className="col-span-1 text-sm text-gray-500 dark:text-gray-400"> <div className="col-span-1 text-sm text-gray-500 dark:text-gray-400">
<span className="text-indigo-600 dark:text-gray-300 hover:text-indigo-900 cursor-pointer" onClick={toggleUpdate}> <span className="text-indigo-600 dark:text-gray-300 hover:text-indigo-900 cursor-pointer" onClick={toggleUpdate}>
Edit Edit

View file

@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";

View file

@ -65,3 +65,21 @@ export function slugify(str: string) {
.trim() .trim()
.replace(/[-\s]+/g, "-"); .replace(/[-\s]+/g, "-");
} }
// WARNING: This is not a drop in replacement solution and
// it might not work for some edge cases. Test your code!
export const get = <T> (obj: T, path: string|Array<any>, defValue?: string) => {
// If path is not defined or it has false value
if (!path)
return undefined;
// Check if path is string or array. Regex : ensure that we do not have '.' and brackets.
// Regex explained: https://regexr.com/58j0k
const pathArray = Array.isArray(path) ? path : path.match(/([^[.\]])+/g);
// Find value
const result = pathArray && pathArray.reduce(
(prevObj, key) => prevObj && prevObj[key],
obj
);
// If found value is undefined return default value; otherwise return the value
return result === undefined ? defValue : result;
};

File diff suppressed because it is too large Load diff