From f09f5ee85336f5fec1cf976ed658989e828dd05c Mon Sep 17 00:00:00 2001 From: Michal Miszczyszyn Date: Thu, 7 Jan 2021 19:42:16 +0100 Subject: [PATCH 1/4] table poc --- components/AuthGuard/AuthGuard.tsx | 21 +---- components/Table/Table.tsx | 128 ++++++++++++++++++++++++----- components/Table/table.module.scss | 13 +-- hooks/useSearch.tsx | 4 +- package.json | 1 + pages/api/blogs/index.ts | 38 ++++----- yarn.lock | 5 ++ 7 files changed, 142 insertions(+), 68 deletions(-) diff --git a/components/AuthGuard/AuthGuard.tsx b/components/AuthGuard/AuthGuard.tsx index 38247470..2eb174b4 100644 --- a/components/AuthGuard/AuthGuard.tsx +++ b/components/AuthGuard/AuthGuard.tsx @@ -14,7 +14,7 @@ export const AuthGuard: React.FC = ({ children, role }) => { useEffect(() => { if (!isLoading && !session) { - void signIn(); + // void signIn(); } }, [session, isLoading]); @@ -22,22 +22,5 @@ export const AuthGuard: React.FC = ({ children, role }) => { return ; } - // Without role allow all authorized users - if (!role && session) { - return <>{children}; - } - - if (role === 'admin' && session?.user.role === 'ADMIN') { - return <>{children}; - } - - return ( -
-

Brak uprawnień

-

- Nie masz odpowiednich uprawnień, żeby korzystać z tej podstrony. W celu weryfikacji - skontaktuj się z administracją serwisu. -

-
- ); + return <>{children}; }; diff --git a/components/Table/Table.tsx b/components/Table/Table.tsx index 0323a67f..0e0ac114 100644 --- a/components/Table/Table.tsx +++ b/components/Table/Table.tsx @@ -1,3 +1,5 @@ +import { useEffect, useMemo, useRef } from 'react'; + import styles from './table.module.scss'; type TableProps = { @@ -5,25 +7,111 @@ type TableProps = { readonly columns: ReadonlyArray; }; -export const Table = ({ data, columns }: TableProps) => ( -
- - - - {columns.map(([key, label]) => ( - - ))} - - - - {data.map((row) => ( - - {columns.map(([key]) => ( - +const rafThrottle = ( + fn: (...args: T) => any, +): ((...args: T) => void) => { + if (typeof window === 'undefined') { + return () => {}; + } + const fnToThrottle = window.requestIdleCallback || window.requestAnimationFrame; + + let isPainting = false; + return (...args) => { + if (isPainting) { + return; + } + isPainting = true; + fnToThrottle( + () => { + fn(...args); + setTimeout(() => (isPainting = false)); + }, + { timeout: 100 }, + ); + }; +}; + +const cssPropsToCopy = [ + 'display', + 'verticalAlign', + 'textAlign', + 'font', + 'background', + 'borderTop', + 'borderRight', + 'borderBottom', + 'borderLeft', + 'padding', +] as const; + +const useFloatingTableHeader = () => { + const tableRef = useRef(null); + const targetRef = useRef(null); + + const update = useMemo( + () => + rafThrottle(() => { + if (!tableRef.current || !targetRef.current) { + return; + } + + const headers = tableRef.current.querySelectorAll('thead th'); + const newElements = Array.from(headers).map((th) => { + const thRect = th.getBoundingClientRect(); + const thStyle = getComputedStyle(th); + const span = document.createElement('span'); + span.innerHTML = th.innerHTML; + span.style.height = `${thRect.height}px`; + span.style.width = `${thRect.width}px`; + cssPropsToCopy.forEach((prop) => (span.style[prop] = thStyle[prop])); + return span; + }); + + const tableRect = tableRef.current.getBoundingClientRect(); + targetRef.current.innerHTML = ''; + targetRef.current.style.zIndex = '1'; + targetRef.current.style.position = 'absolute'; + targetRef.current.style.width = `${tableRect.width}px`; + newElements.forEach((el) => targetRef.current?.appendChild(el)); + + console.log(tableRef.current, targetRef.current); + }), + [], + ); + + useEffect(() => { + update(); + + window.addEventListener('resize', update, { passive: true }); + return () => window.removeEventListener('resize', update); + }); + + return { tableRef, targetRef }; +}; + +export const Table = ({ data, columns }: TableProps) => { + const { tableRef, targetRef } = useFloatingTableHeader(); + return ( +
+
+
{label}
{row[key]}
+ + + {columns.map(([key, label]) => ( + ))} - ))} - -
{label}
-
-); + + + {data.map((row) => ( + + {columns.map(([key]) => ( + {row[key]} + ))} + + ))} + + + + ); +}; diff --git a/components/Table/table.module.scss b/components/Table/table.module.scss index 410b0871..aa9feabe 100644 --- a/components/Table/table.module.scss +++ b/components/Table/table.module.scss @@ -8,6 +8,10 @@ padding: 0.5rem 1rem; border: 1px solid var(--gray-dark-border); + &:nth-child(2) { + width: 27vw; + } + & + th, & + td { border-left: none; @@ -34,10 +38,7 @@ .tableWrapper { width: 100%; - overflow: auto; - /* - In order to be able to make elements inside "sticky", the container has to have a fixed height - https://uxdesign.cc/position-stuck-96c9f55d9526 - */ - height: 80vh; + overflow-x: auto; + position: relative; + -webkit-overflow-scrolling: touch; } diff --git a/hooks/useSearch.tsx b/hooks/useSearch.tsx index f23f0f37..471edf39 100644 --- a/hooks/useSearch.tsx +++ b/hooks/useSearch.tsx @@ -4,7 +4,7 @@ import type { InferType, ObjectSchema } from 'yup'; const replacementsPattern = /\[([^[\]\s]+)\]/gi; -export const useSearch = >(schema: S) => { +export const useSmartQuery = >(schema: S) => { const { pathname, query: routerQuery, push } = useRouter(); /** @@ -30,8 +30,6 @@ export const useSearch = >(schema: S) => { }; }, [pathname, routerQuery, schema]); - console.log({ params, query }); - const changeQuery = useCallback( (query: InferType) => { const filteredQuery = Object.fromEntries( diff --git a/package.json b/package.json index 14d2d2f1..eb35cd4f 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@types/pino": "6.3.4", "@types/react": "17.0.0", "@types/react-dom": "17.0.0", + "@types/requestidlecallback": "0.3.1", "@types/yup": "0.29.11", "@types/zeit__next-source-maps": "0.0.2", "@typescript-eslint/eslint-plugin": "4.12.0", diff --git a/pages/api/blogs/index.ts b/pages/api/blogs/index.ts index 6d383ab1..13aee98c 100644 --- a/pages/api/blogs/index.ts +++ b/pages/api/blogs/index.ts @@ -4,27 +4,25 @@ import { boolean, object } from 'yup'; import { withAsync, withValidation, withAuth, withDb } from '../../../api-helpers/api-hofs'; export default withAsync( - withAuth('ADMIN')( - withValidation({ - query: object({ - isPublic: boolean().optional(), - }).optional(), - })( - withDb(async (req) => { - if (req.method !== 'GET') { - throw Boom.notFound(); - } + withValidation({ + query: object({ + isPublic: boolean().optional(), + }).optional(), + })( + withDb(async (req) => { + if (req.method !== 'GET') { + throw Boom.notFound(); + } - const blogs = await req.db.blog.findMany({ - where: { - isPublic: req.query.isPublic, - }, - }); + const blogs = await req.db.blog.findMany({ + where: { + isPublic: req.query.isPublic, + }, + }); - return { - data: blogs, - }; - }), - ), + return { + data: blogs, + }; + }), ), ); diff --git a/yarn.lock b/yarn.lock index 13161f1b..747522b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1970,6 +1970,11 @@ "@types/prop-types" "*" csstype "^3.0.2" +"@types/requestidlecallback@0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@types/requestidlecallback/-/requestidlecallback-0.3.1.tgz#34bb89753b1cdc72d0547522527b1cb0f02b5ec4" + integrity sha512-BnnRkgWYijCIndUn+LgoqKHX/hNpJC5G03B9y7mZya/C2gUQTSn75fEj3ZP1/Rl2E6EYeXh2/7/8UNEZ4X7HuQ== + "@types/sax@*": version "1.2.1" resolved "https://registry.yarnpkg.com/@types/sax/-/sax-1.2.1.tgz#e0248be936ece791a82db1a57f3fb5f7c87e8172" From 632d905ac7d2c5be8cda9bede754838a5b99d810 Mon Sep 17 00:00:00 2001 From: Michal Miszczyszyn Date: Fri, 8 Jan 2021 14:24:41 +0100 Subject: [PATCH 2/4] Uproszczenie --- components/Table/Table.tsx | 116 +++++---------------- components/Table/useFloatingTableHeader.ts | 79 ++++++++++++++ 2 files changed, 107 insertions(+), 88 deletions(-) create mode 100644 components/Table/useFloatingTableHeader.ts diff --git a/components/Table/Table.tsx b/components/Table/Table.tsx index 0e0ac114..13916d67 100644 --- a/components/Table/Table.tsx +++ b/components/Table/Table.tsx @@ -1,112 +1,52 @@ -import { useEffect, useMemo, useRef } from 'react'; +import type { CSSProperties } from 'react'; import styles from './table.module.scss'; +import { useFloatingTableHeader } from './useFloatingTableHeader'; -type TableProps = { +type TableProps = { readonly data: readonly T[]; - readonly columns: ReadonlyArray; + readonly columns: ReadonlyArray; }; -const rafThrottle = ( - fn: (...args: T) => any, -): ((...args: T) => void) => { - if (typeof window === 'undefined') { - return () => {}; - } - const fnToThrottle = window.requestIdleCallback || window.requestAnimationFrame; - - let isPainting = false; - return (...args) => { - if (isPainting) { - return; - } - isPainting = true; - fnToThrottle( - () => { - fn(...args); - setTimeout(() => (isPainting = false)); - }, - { timeout: 100 }, - ); - }; -}; - -const cssPropsToCopy = [ - 'display', - 'verticalAlign', - 'textAlign', - 'font', - 'background', - 'borderTop', - 'borderRight', - 'borderBottom', - 'borderLeft', - 'padding', -] as const; - -const useFloatingTableHeader = () => { - const tableRef = useRef(null); - const targetRef = useRef(null); - - const update = useMemo( - () => - rafThrottle(() => { - if (!tableRef.current || !targetRef.current) { - return; - } - - const headers = tableRef.current.querySelectorAll('thead th'); - const newElements = Array.from(headers).map((th) => { - const thRect = th.getBoundingClientRect(); - const thStyle = getComputedStyle(th); - const span = document.createElement('span'); - span.innerHTML = th.innerHTML; - span.style.height = `${thRect.height}px`; - span.style.width = `${thRect.width}px`; - cssPropsToCopy.forEach((prop) => (span.style[prop] = thStyle[prop])); - return span; - }); - - const tableRect = tableRef.current.getBoundingClientRect(); - targetRef.current.innerHTML = ''; - targetRef.current.style.zIndex = '1'; - targetRef.current.style.position = 'absolute'; - targetRef.current.style.width = `${tableRect.width}px`; - newElements.forEach((el) => targetRef.current?.appendChild(el)); - - console.log(tableRef.current, targetRef.current); - }), - [], +const TableHeaderRow = ({ + as: As, + columns, + styles, +}: { + readonly as: 'span' | 'th'; + readonly styles?: readonly CSSProperties[]; + readonly columns: readonly (readonly [string, string])[]; +}) => { + return ( + <> + {columns.map(([key, label], index) => ( + + {label} + + ))} + ); - - useEffect(() => { - update(); - - window.addEventListener('resize', update, { passive: true }); - return () => window.removeEventListener('resize', update); - }); - - return { tableRef, targetRef }; }; export const Table = ({ data, columns }: TableProps) => { - const { tableRef, targetRef } = useFloatingTableHeader(); + const { tableRef, floatingHeaderRef } = useFloatingTableHeader(); + return (
-
+
+ +
- {columns.map(([key, label]) => ( - - ))} + {data.map((row) => ( {columns.map(([key]) => ( - + ))} ))} diff --git a/components/Table/useFloatingTableHeader.ts b/components/Table/useFloatingTableHeader.ts new file mode 100644 index 00000000..ef6201f1 --- /dev/null +++ b/components/Table/useFloatingTableHeader.ts @@ -0,0 +1,79 @@ +import { useEffect, useMemo, useRef } from 'react'; + +const rafThrottle = ( + fn: (...args: T) => any, +): ((...args: T) => void) => { + if (typeof window === 'undefined') { + return () => {}; + } + const fnToThrottle = window.requestIdleCallback || window.requestAnimationFrame; + + let isPainting = false; + return (...args) => { + if (isPainting) { + return; + } + isPainting = true; + fnToThrottle( + () => { + fn(...args); + setTimeout(() => (isPainting = false)); + }, + { timeout: 100 }, + ); + }; +}; + +const cssPropsToCopy = [ + 'display', + 'verticalAlign', + 'textAlign', + 'font', + 'background', + 'borderTop', + 'borderRight', + 'borderBottom', + 'borderLeft', + 'padding', +] as const; + +export const useFloatingTableHeader = () => { + const tableRef = useRef(null); + const floatingHeaderRef = useRef(null); + + const update = useMemo( + () => + rafThrottle(() => { + if (!tableRef.current || !floatingHeaderRef.current) { + return; + } + + const headers = tableRef.current.querySelectorAll('thead th'); + const spans = floatingHeaderRef.current.querySelectorAll('span'); + const tableRect = tableRef.current.getBoundingClientRect(); + + Array.from(headers) + .map((th) => ({ thRect: th.getBoundingClientRect(), thStyle: getComputedStyle(th) })) + .forEach(({ thRect, thStyle }, index) => { + spans[index].style.height = `${thRect.height}px`; + spans[index].style.width = `${thRect.width}px`; + cssPropsToCopy.forEach((prop) => (spans[index].style[prop] = thStyle[prop])); + }); + + floatingHeaderRef.current.style.zIndex = '1'; + floatingHeaderRef.current.style.position = 'absolute'; + floatingHeaderRef.current.style.top = '0'; + floatingHeaderRef.current.style.width = `${tableRect.width}px`; + }), + [], + ); + + useEffect(() => { + update(); + + window.addEventListener('resize', update, { passive: true }); + return () => window.removeEventListener('resize', update); + }, [update]); + + return { tableRef, floatingHeaderRef }; +}; From 891592be8f3095e2e04d4e92472a2613c4385101 Mon Sep 17 00:00:00 2001 From: Michal Miszczyszyn Date: Fri, 8 Jan 2021 15:10:33 +0100 Subject: [PATCH 3/4] Fix --- components/AuthGuard/AuthGuard.tsx | 21 +++++++++++++++-- pages/api/blogs/index.ts | 38 ++++++++++++++++-------------- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/components/AuthGuard/AuthGuard.tsx b/components/AuthGuard/AuthGuard.tsx index 2eb174b4..38247470 100644 --- a/components/AuthGuard/AuthGuard.tsx +++ b/components/AuthGuard/AuthGuard.tsx @@ -14,7 +14,7 @@ export const AuthGuard: React.FC = ({ children, role }) => { useEffect(() => { if (!isLoading && !session) { - // void signIn(); + void signIn(); } }, [session, isLoading]); @@ -22,5 +22,22 @@ export const AuthGuard: React.FC = ({ children, role }) => { return ; } - return <>{children}; + // Without role allow all authorized users + if (!role && session) { + return <>{children}; + } + + if (role === 'admin' && session?.user.role === 'ADMIN') { + return <>{children}; + } + + return ( +
+

Brak uprawnień

+

+ Nie masz odpowiednich uprawnień, żeby korzystać z tej podstrony. W celu weryfikacji + skontaktuj się z administracją serwisu. +

+
+ ); }; diff --git a/pages/api/blogs/index.ts b/pages/api/blogs/index.ts index 13aee98c..6d383ab1 100644 --- a/pages/api/blogs/index.ts +++ b/pages/api/blogs/index.ts @@ -4,25 +4,27 @@ import { boolean, object } from 'yup'; import { withAsync, withValidation, withAuth, withDb } from '../../../api-helpers/api-hofs'; export default withAsync( - withValidation({ - query: object({ - isPublic: boolean().optional(), - }).optional(), - })( - withDb(async (req) => { - if (req.method !== 'GET') { - throw Boom.notFound(); - } + withAuth('ADMIN')( + withValidation({ + query: object({ + isPublic: boolean().optional(), + }).optional(), + })( + withDb(async (req) => { + if (req.method !== 'GET') { + throw Boom.notFound(); + } - const blogs = await req.db.blog.findMany({ - where: { - isPublic: req.query.isPublic, - }, - }); + const blogs = await req.db.blog.findMany({ + where: { + isPublic: req.query.isPublic, + }, + }); - return { - data: blogs, - }; - }), + return { + data: blogs, + }; + }), + ), ), ); From 6f30e739f47d4545017b50e39b6662eedf59db44 Mon Sep 17 00:00:00 2001 From: Michal Miszczyszyn Date: Fri, 8 Jan 2021 21:29:38 +0100 Subject: [PATCH 4/4] useIntersectionObserver --- components/Table/Table.tsx | 13 +++- hooks/useIntersectionObserver.tsx | 100 ++++++++++++++++++++++++++++++ hooks/useWillUnmount.tsx | 7 +++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 hooks/useIntersectionObserver.tsx create mode 100644 hooks/useWillUnmount.tsx diff --git a/components/Table/Table.tsx b/components/Table/Table.tsx index 13916d67..0f425bb1 100644 --- a/components/Table/Table.tsx +++ b/components/Table/Table.tsx @@ -1,4 +1,7 @@ import type { CSSProperties } from 'react'; +import { useEffect } from 'react'; + +import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'; import styles from './table.module.scss'; import { useFloatingTableHeader } from './useFloatingTableHeader'; @@ -28,16 +31,24 @@ const TableHeaderRow = ({ ); }; +const intersectionObserverOptions = {}; + export const Table = ({ data, columns }: TableProps) => { const { tableRef, floatingHeaderRef } = useFloatingTableHeader(); + const { setRef, entry } = useIntersectionObserver( + intersectionObserverOptions, + ); + + console.log({ entry }); + return (
{label}
{row[key]}{row[key]}
- + diff --git a/hooks/useIntersectionObserver.tsx b/hooks/useIntersectionObserver.tsx new file mode 100644 index 00000000..628d69e6 --- /dev/null +++ b/hooks/useIntersectionObserver.tsx @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useWillUnmount } from './useWillUnmount'; + +type UseIntersectionObserverArgs = Pick; +type ObserverCallback = (entry: IntersectionObserverEntry) => void; +type Observer = { + readonly key: string; + readonly intersectionObserver: IntersectionObserver; + // eslint-disable-next-line + readonly elementToCallback: Map; +}; + +export const useIntersectionObserver = ( + options: UseIntersectionObserverArgs, +) => { + const unobserve = useRef<() => void>(); + + const [entry, setEntry] = useState(null); + + const setRef = useCallback( + (el: T) => { + console.log({ el }); + + if (unobserve.current) { + unobserve.current(); + unobserve.current = undefined; + } + + if (el && el.tagName) { + unobserve.current = observe(el, setEntry, options); + } + }, + [options], + ); + + useWillUnmount(() => { + if (unobserve.current) { + unobserve.current(); + unobserve.current = undefined; + } + }); + + if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') { + return { setRef: () => {}, entry: null } as const; + } + + return { setRef, entry } as const; +}; + +const observe = (() => { + const observers = new Map(); + + const createObserver = (options: UseIntersectionObserverArgs) => { + const key = JSON.stringify(options); + if (observers.has(key)) { + return observers.get(key)!; + } + + const elementToCallback = new Map(); + const intersectionObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + const callback = elementToCallback.get(entry.target); + if (callback) { + callback(entry); + } + }); + }, options); + + const observer: Observer = { + key, + elementToCallback, + intersectionObserver, + }; + observers.set(key, observer); + + return observer; + }; + + return ( + el: T, + callback: ObserverCallback, + options: UseIntersectionObserverArgs, + ) => { + const { key, elementToCallback, intersectionObserver } = createObserver(options); + elementToCallback.set(el, callback); + intersectionObserver.observe(el); + + const unobserve = () => { + intersectionObserver.unobserve(el); + elementToCallback.delete(el); + + if (elementToCallback.size === 0) { + intersectionObserver.disconnect(); + observers.delete(key); + } + }; + return unobserve; + }; +})(); diff --git a/hooks/useWillUnmount.tsx b/hooks/useWillUnmount.tsx new file mode 100644 index 00000000..a3161a2a --- /dev/null +++ b/hooks/useWillUnmount.tsx @@ -0,0 +1,7 @@ +import type { EffectCallback } from 'react'; +import { useEffect } from 'react'; + +/* eslint-disable react-hooks/exhaustive-deps */ +export const useWillUnmount = (cb: ReturnType) => { + useEffect(() => cb, []); +};