some wild stuff, modals, dropdowns, forms etc

This commit is contained in:
Evert Prants 2022-08-31 19:24:45 +03:00
parent fd3c4cc3af
commit 3feff2a367
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
16 changed files with 422 additions and 47 deletions

View File

@ -6,6 +6,11 @@
padding: 0.5rem;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
.titleWrap {
display: flex;
justify-content: space-between;
}
.pictureWrapper {
overflow: hidden;
}
@ -13,6 +18,7 @@
.clientInfo {
display: flex;
flex-direction: column;
flex-grow: 1;
h2 {
padding: 0;

View File

@ -1,12 +1,16 @@
import useSWR from 'swr';
import Image from 'next/image';
import { OAuth2ClientListItem } from '../../lib/types/oauth2-client.interface';
import {
OAuth2ClientListItem,
OAuth2ClientURL,
OAuth2ClientURLType,
} from '../../lib/types/oauth2-client.interface';
import { PaginatedResponse } from '../../lib/types/paginated-response.interface';
import { Paginator } from '../common/Paginator/Paginator';
import styles from './OAuth2Page.module.scss';
import { UPLOADS_URL } from '../../lib/constants';
import application from '../../public/application.png';
import { useState } from 'react';
import { ChangeEvent, useMemo, useRef, useState } from 'react';
import useUser from '../../lib/hooks/useUser';
import { Container } from '../common/Container/Container';
import { Header } from '../common/Header/Header';
@ -15,18 +19,147 @@ import ModalHeader from '../common/Modal/ModalHeader/ModalHeader';
import ModalBody from '../common/Modal/ModalBody/ModalBody';
import ModalFooter from '../common/Modal/ModalFooter/ModalFooter';
import ModalService from '../common/Modal/services/ModalService';
import { Dropdown } from '../common/Dropdown/Dropdown';
import { ModalProps } from '../../lib/types/modal.interface';
import { useForm } from '../../lib/hooks/useForm';
import { FormWrapper } from '../common/Form/FormWrapper/FormWrapper';
import { FormControl } from '../common/Form/FormControl/FormControl';
const TestModal = ({ close }: { close: (...args: any[]) => void }) => {
const LINK_NAMES = {
redirect_uri: 'Redirect URI',
terms: 'Terms of Service',
privacy: 'Privacy Policy',
website: 'Website',
};
const LinkEdit = ({
formData,
handleInputChange,
linkType,
}: {
formData: Partial<OAuth2ClientListItem>;
handleInputChange: (
e: ChangeEvent,
setValue?: any,
formField?: string
) => void;
linkType: OAuth2ClientURLType;
}) => {
const formUrl = useMemo<Partial<OAuth2ClientURL>>(
() => (formData.urls || []).find(({ type }) => type === linkType) || {},
[formData, linkType]
);
return (
<Modal>
<FormControl>
<label htmlFor={linkType}>{LINK_NAMES[linkType]}</label>
<input
id={linkType}
name={linkType}
value={formUrl?.url || ''}
onChange={(e) => {
if (!formUrl.type) {
formUrl.type = linkType;
(formData.urls as Partial<OAuth2ClientURL>[])?.push(formUrl);
}
formUrl.url = e.target.value;
handleInputChange(e, formData.urls, 'urls');
}}
/>
</FormControl>
);
};
const EditClientModal = ({
close,
modalRef,
client,
}: ModalProps<{ client?: OAuth2ClientListItem }>) => {
const formRef = useRef<HTMLFormElement>(null);
const { formData, handleInputChange, handleSubmit } = useForm<
Partial<OAuth2ClientListItem>
>(client || {}, () => {
console.log(formData);
return false;
});
return (
<Modal modalRef={modalRef}>
<ModalHeader>
<h3>Test!</h3>
<h3>Edit OAuth2 Client</h3>
</ModalHeader>
<ModalBody>
<p>This is only a test</p>
<FormWrapper>
<form ref={formRef} onSubmit={handleSubmit}>
<FormControl>
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
name="title"
value={formData.title}
onChange={handleInputChange}
/>
</FormControl>
<FormControl>
<label htmlFor="description">Description</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleInputChange}
/>
</FormControl>
<FormControl>
<label htmlFor="scope">Allowed scopes</label>
<input
id="scope"
name="scope"
value={formData.scope}
onChange={handleInputChange}
/>
</FormControl>
<FormControl>
<label htmlFor="grants">Allowed grant types</label>
<input
id="grants"
name="grants"
value={formData.grants}
onChange={handleInputChange}
/>
</FormControl>
<FormControl inline={true}>
<label htmlFor="activated">Activated</label>
<input
id="activated"
name="activated"
type="checkbox"
checked={formData.activated}
onChange={handleInputChange}
/>
</FormControl>
<FormControl inline={true}>
<label htmlFor="verified">Verified</label>
<input
id="verified"
name="verified"
type="checkbox"
checked={formData.verified}
onChange={handleInputChange}
/>
</FormControl>
<LinkEdit
formData={formData}
handleInputChange={handleInputChange}
linkType={OAuth2ClientURLType.REDIRECT_URI}
></LinkEdit>
</form>
</FormWrapper>
</ModalBody>
<ModalFooter>
<button onClick={() => close(true)}>Close</button>
<button onClick={() => close(true)}>Cancel</button>
<button>New secret</button>
<button onClick={() => handleSubmit()}>Submit</button>
</ModalFooter>
</Modal>
);
@ -47,7 +180,19 @@ const OAuth2ClientCard = ({ client }: { client: OAuth2ClientListItem }) => (
)}
</div>
<div className={styles.clientInfo}>
<h2>{client.title}</h2>
<div className={styles.titleWrap}>
<h2>{client.title}</h2>
<Dropdown opens="right">
<button
onClick={() =>
ModalService.open(EditClientModal, { client: client })
}
>
Edit client
</button>
{!client.activated && <button>Delete client</button>}
</Dropdown>
</div>
<span className={styles.clientDescription}>{client.description}</span>
<dl>
<dt>Client ID</dt>
@ -84,7 +229,6 @@ const OAuth2ClientList = ({
return data ? (
<>
<button onClick={() => ModalService.open(TestModal)}>test</button>
<div className={styles.clientList}>
{data.list.map((client) => (
<OAuth2ClientCard client={client} key={client.client_id} />

View File

@ -0,0 +1,77 @@
.wrapper {
position: relative;
.toggle {
appearance: none;
border: 1px solid #ddd;
background: #ffffff;
border-radius: 4px;
width: 32px;
height: 32px;
cursor: pointer;
&.active,
&:hover,
&:focus-visible {
background-color: rgb(240, 240, 240);
}
&.active {
border-bottom: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
&::after {
content: '';
font-weight: bold;
font-size: 1.25rem;
}
}
.dropdown {
position: absolute;
display: flex;
flex-direction: column;
top: 100%;
min-width: 140px;
background-color: #fff;
border: 1px solid #ddd;
border-radius: 4px;
a,
button {
appearance: none;
border: 0;
background: transparent;
font-size: 1rem;
text-align: left;
padding: 0.5rem 0.5rem;
border-bottom: 1px solid #ddd;
cursor: pointer;
&:hover,
&:focus-visible {
background-color: rgb(240, 240, 240);
}
&:last-child {
border-bottom: 0;
}
}
}
&.right {
.dropdown {
right: 0;
border-top-right-radius: 0;
}
}
&.left {
.dropdown {
left: 0;
border-top-left-radius: 0;
}
}
}

View File

@ -0,0 +1,45 @@
import { useEffect, useRef, useState } from 'react';
import conditionalClass from '../../../lib/utils/conditional-class';
import styles from './Dropdown.module.scss';
export const Dropdown = ({
children,
opens = 'left',
}: {
children: React.ReactNode;
opens?: 'right' | 'left';
}) => {
const [visible, setVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Off click listener
useEffect(() => {
if (!visible || !ref) return;
function handleClickOutside(event: MouseEvent | TouchEvent) {
if (ref.current && !ref.current.contains(event.target as HTMLElement)) {
setVisible(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside);
};
}, [ref, visible]);
return (
<div className={[styles.wrapper, styles[opens]].join(' ')} ref={ref}>
<button
className={[
styles.toggle,
conditionalClass(visible, styles.active),
].join(' ')}
onClick={() => setVisible(!visible)}
></button>
{visible && <div className={styles.dropdown}>{children}</div>}
</div>
);
};

View File

@ -0,0 +1,20 @@
.wrapper {
form {
display: flex;
flex-direction: column;
}
}
.control {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
&.inline {
flex-direction: row;
input[type='checkbox'] {
margin-left: 1rem;
}
}
}

View File

@ -0,0 +1,20 @@
import conditionalClass from '../../../../lib/utils/conditional-class';
import styles from '../Form.module.scss';
export const FormControl = ({
children,
inline = false,
}: {
children: React.ReactNode;
inline?: boolean;
}) => {
return (
<div
className={[styles.control, conditionalClass(inline, styles.inline)].join(
' '
)}
>
{children}
</div>
);
};

View File

@ -0,0 +1,5 @@
import styles from '../Form.module.scss';
export const FormWrapper = ({ children }: { children: React.ReactNode }) => {
return <div className={styles.wrapper}>{children}</div>;
};

View File

@ -21,11 +21,14 @@
.body {
padding: 1rem;
max-height: 50vh;
overflow: auto;
}
.footer {
padding: 1rem;
border-top: 1px solid #ddd;
gap: 0.5rem;
display: flex;
justify-content: flex-end;
}

View File

@ -1,5 +1,16 @@
import { RefObject } from 'react';
import styles from './Modal.module.scss';
export default function Modal({ children }: { children: JSX.Element[] }) {
return <div className={styles.modal}>{children}</div>;
export default function Modal({
children,
modalRef,
}: {
children: React.ReactNode;
modalRef: RefObject<HTMLDivElement>;
}) {
return (
<div className={styles.modal} ref={modalRef}>
{children}
</div>
);
}

View File

@ -1,9 +1,5 @@
import styles from '../Modal/Modal.module.scss';
export default function ModalBody({
children,
}: {
children: JSX.Element | [];
}) {
export default function ModalBody({ children }: { children: React.ReactNode }) {
return <div className={styles.body}>{children}</div>;
}

View File

@ -3,7 +3,7 @@ import styles from '../Modal/Modal.module.scss';
export default function ModalFooter({
children,
}: {
children: JSX.Element | [];
children: React.ReactNode;
}) {
return <div className={styles.footer}>{children}</div>;
}

View File

@ -3,7 +3,7 @@ import styles from '../Modal/Modal.module.scss';
export default function ModalHeader({
children,
}: {
children: JSX.Element | [];
children: React.ReactNode;
}) {
return <div className={styles.header}>{children}</div>;
}

View File

@ -1,16 +1,18 @@
import { useState, useEffect } from 'react';
import { ModalDetail } from '../../../../lib/types/modal.interface';
import { useState, useEffect, useRef } from 'react';
import { ModalSetter } from '../../../../lib/types/modal.interface';
import ModalService from '../services/ModalService';
import styles from './ModalRoot.module.scss';
export default function ModalRoot() {
const [modal, setModal] = useState<ModalDetail<unknown> | null>(null);
const [modal, setModal] = useState<ModalSetter<Object> | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
ModalService.on('open', ({ component, props, target, resolve }) => {
setModal({
component,
props,
resolve,
close: (...args) => {
setModal(null);
resolve?.call(null, ...args);
@ -21,19 +23,36 @@ export default function ModalRoot() {
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
modal?.close?.call(null, false);
}
};
if (!modal) {
return;
}
window.addEventListener('keyup', handler);
function handleEscapeKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
modal?.close?.call(null, false);
}
}
// Off click listener
function handleClickOutside(event: MouseEvent | TouchEvent) {
if (
ref.current &&
!(ref.current as unknown as HTMLElement).contains(
event.target as HTMLElement
)
) {
modal?.close?.call(null, false);
}
}
window.addEventListener('keyup', handleEscapeKey);
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleClickOutside);
return () => {
window.removeEventListener('keyup', handler);
window.removeEventListener('keyup', handleEscapeKey);
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside);
};
}, [modal]);
@ -42,11 +61,7 @@ export default function ModalRoot() {
return (
<section className={modal?.component ? styles.modalRoot : ''}>
{ModalComponent && (
<ModalComponent
{...modal?.props}
close={modal?.close}
className={ModalComponent ? 'd-block' : ''}
/>
<ModalComponent {...modal?.props} modalRef={ref} close={modal!.close} />
)}
</section>
);

View File

@ -1,15 +1,18 @@
import { ModalType, ModalDetail } from '../../../../lib/types/modal.interface';
import {
ModalType,
ModalEventDetail,
} from '../../../../lib/types/modal.interface';
const ModalService = {
on(event: string, callback: (props: ModalDetail<unknown>) => void) {
on(event: string, callback: (props: ModalEventDetail<Object>) => void) {
document.addEventListener(event, (e: Event) =>
callback((e as CustomEvent<ModalDetail<unknown>>).detail)
callback((e as CustomEvent<ModalEventDetail<Object>>).detail)
);
},
open<T>(component: ModalType<T>, props: any = {}) {
return new Promise((resolve, _) => {
document.dispatchEvent(
new CustomEvent<ModalDetail<T>>('open', {
new CustomEvent<ModalEventDetail<T>>('open', {
detail: {
component,
props,

24
lib/hooks/useForm.ts Normal file
View File

@ -0,0 +1,24 @@
import { ChangeEvent, FormEvent, useState } from 'react';
export function useForm<T>(initialState: T, onSubmit: (data: T) => void) {
const [formData, setFormData] = useState<T>(initialState);
const handleInputChange = (
e: ChangeEvent,
setValue?: any,
formField?: string
) => {
const target = e.target as HTMLInputElement;
setFormData({
...formData,
[formField || target.name]: setValue ?? target.checked ?? target.value,
});
};
const handleSubmit = (e?: FormEvent) => {
e?.preventDefault();
onSubmit(formData);
};
return { formData, handleInputChange, handleSubmit };
}

View File

@ -1,13 +1,19 @@
import React from 'react';
import React, { RefObject } from 'react';
export type ModalType<T> = React.ElementType<
T & { close: (...args: any[]) => void }
>;
export type ModalProps<T = Object> = T & {
close: (...args: any[]) => void;
modalRef: RefObject<HTMLDivElement>;
};
export interface ModalDetail<T> {
component: ModalType<T>;
props: any;
export type ModalType<T> = React.ElementType<any & ModalProps<T>>;
export interface ModalEventDetail<K = Object> {
component: ModalType<K>;
props: K;
resolve: (...args: any[]) => void;
target?: HTMLElement;
resolve?: (...args: any[]) => void;
close?: (...args: any[]) => void;
}
export interface ModalSetter<K = Object> extends ModalEventDetail<K> {
close: (...args: any[]) => void;
}