@star-insure/sdk 3.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ /// <reference types="react" />
2
+ import { TPageHeaderAction } from "../../types";
3
+ export default function Action({ title, href, as, target, type, onClick }: TPageHeaderAction): JSX.Element;
@@ -0,0 +1,4 @@
1
+ /// <reference types="react" />
2
+ export declare function BackButton({ back }: {
3
+ back?: string | boolean;
4
+ }): JSX.Element;
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ declare type Props = {
3
+ children: React.ReactNode;
4
+ onClose: Function;
5
+ active: boolean;
6
+ };
7
+ declare function Dropdown({ children, onClose, active }: Props): JSX.Element;
8
+ declare namespace Dropdown {
9
+ var Title: "button";
10
+ var Content: "form";
11
+ }
12
+ export default Dropdown;
@@ -0,0 +1,6 @@
1
+ /// <reference types="react" />
2
+ import { FilterOption } from "../../types";
3
+ export declare function FilterItem({ filter }: {
4
+ filter: FilterOption;
5
+ path?: string;
6
+ }): JSX.Element;
@@ -0,0 +1,5 @@
1
+ /// <reference types="react" />
2
+ import { FilterOption } from "../../types";
3
+ export declare function FilterOptions({ filterOptions }: {
4
+ filterOptions: FilterOption[];
5
+ }): JSX.Element;
@@ -0,0 +1,14 @@
1
+ /// <reference types="react" />
2
+ import { FilterOption, TPageHeaderAction } from '../../types';
3
+ interface Props {
4
+ title: string;
5
+ search?: string;
6
+ className?: string;
7
+ innerClassName?: string;
8
+ back?: boolean | string;
9
+ actions?: TPageHeaderAction[];
10
+ backText?: string;
11
+ filterOptions?: FilterOption[];
12
+ }
13
+ export default function PageHeader({ title, search, className, innerClassName, back, actions, filterOptions, }: Props): JSX.Element;
14
+ export {};
@@ -0,0 +1,7 @@
1
+ /// <reference types="react" />
2
+ export default function SearchBar({ search, active, onActive, placeholder }: {
3
+ search?: string;
4
+ active: boolean;
5
+ onActive: Function;
6
+ placeholder?: string;
7
+ }): JSX.Element;
@@ -0,0 +1 @@
1
+ export { default as PageHeader } from './PageHeader';
@@ -0,0 +1,19 @@
1
+ import { Page, PageProps } from '@inertiajs/core';
2
+ import { AuthContext, Breadcrumb, Environment } from '../types';
3
+ /**
4
+ * Add custom props here, that are defined in `app/Http/Middleware/HandleInertiaRequests.php`
5
+ */
6
+ declare type CustomPageProps = {
7
+ env: Environment;
8
+ breadcrumbs: Breadcrumb[];
9
+ links: Record<string, string>;
10
+ access_token?: string;
11
+ impersonate_id?: number;
12
+ csrf_token?: string;
13
+ auth: AuthContext;
14
+ } & PageProps;
15
+ /**
16
+ * A wrapper around Inertia's usePage function that gives better type-safety
17
+ */
18
+ export declare function usePage<T>(): Page<CustomPageProps & T>;
19
+ export {};
@@ -5,3 +5,30 @@ export interface Toast {
5
5
  status?: 'success' | 'error' | 'default' | 'warning';
6
6
  timeout?: number;
7
7
  }
8
+ export interface FilterOption {
9
+ label: string;
10
+ name: string;
11
+ options?: FilterValue[];
12
+ type?: 'options' | 'date' | 'greaterThan' | 'scope' | 'select';
13
+ }
14
+ export interface FilterValue {
15
+ label: string;
16
+ value: string | number;
17
+ }
18
+ export interface Filter {
19
+ [key: string]: (string | number)[];
20
+ }
21
+ export declare type TPageHeaderAction = {
22
+ title: string;
23
+ as?: 'button' | 'a' | 'Link';
24
+ href?: string;
25
+ target?: '_self' | '_blank';
26
+ onClick?: Function;
27
+ type?: 'button' | 'submit';
28
+ };
29
+ export declare type Environment = 'production' | 'staging' | 'testing' | 'local';
30
+ export declare type Breadcrumb = {
31
+ current?: boolean;
32
+ title: string;
33
+ url?: string;
34
+ };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@star-insure/sdk",
3
3
  "description": "The SDK for Star Insure client apps with shared helper functions and TypeScript definitions.",
4
4
  "author": "alexclark_nz",
5
- "version": "3.0.3",
5
+ "version": "3.1.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -66,10 +66,13 @@
66
66
  "@headlessui/react": "^1.6.3",
67
67
  "@inertiajs/react": "^1.0.0",
68
68
  "classnames": "^2.3.2",
69
+ "create-slots": "^0.6.1",
69
70
  "date-fns": "^2.28.0",
70
71
  "lodash-es": "^4.17.21",
71
72
  "react": "^18.2.0",
72
73
  "react-dom": "^18.2.0",
74
+ "react-icons": "^4.11.0",
75
+ "react-select": "^5.8.0",
73
76
  "uuid": "^9.0.0"
74
77
  }
75
78
  }
@@ -0,0 +1,30 @@
1
+ import { Link } from "@inertiajs/react";
2
+ import { TPageHeaderAction } from "../../types";
3
+ import React from "react";
4
+
5
+ export default function Action({ title, href, as = 'Link', target = '_self', type, onClick = () => {} }: TPageHeaderAction) {
6
+ const className =
7
+ 'bg-white rounded-full font-bold px-3 py-1.5 text-xs whitespace-nowrap hover:bg-gray-100 hover:border-gray-300 transition-colors border border-gray-200';
8
+
9
+ if (as === 'Link' && href) {
10
+ return (
11
+ <Link className={className} href={href}>
12
+ {title}
13
+ </Link>
14
+ );
15
+ }
16
+
17
+ if (as === 'a' && href) {
18
+ return (
19
+ <a className={className} target={target} href={href}>
20
+ {title}
21
+ </a>
22
+ );
23
+ }
24
+
25
+ return (
26
+ <button className={className} type={type} onClick={() => onClick()}>
27
+ {title}
28
+ </button>
29
+ );
30
+ }
@@ -0,0 +1,35 @@
1
+ import { Link } from "@inertiajs/react";
2
+ import React from "react";
3
+ import { HiArrowLeft } from "react-icons/hi2";
4
+ import { usePage } from "../../lib/page";
5
+
6
+ export function BackButton({ back }: { back?: string | boolean }) {
7
+ const [backUrl, setBackUrl] = React.useState<string | undefined>(typeof back === 'string' ? back : undefined);
8
+ const { breadcrumbs } = usePage().props;
9
+
10
+ /**
11
+ * Set the back URL on mount
12
+ */
13
+ React.useEffect(() => {
14
+ if (typeof window !== 'undefined') {
15
+ // Set back button URL
16
+ if (back && typeof back === 'boolean') {
17
+ // If we haven't provided a path as a prop, use the last breadcrumb that's not the current one
18
+ const crumb = breadcrumbs.slice(breadcrumbs.length - 2, breadcrumbs.length - 1);
19
+ if (crumb && crumb.length === 1) {
20
+ // Strip off everything but the path so the <Link> component works
21
+ const url = crumb[0].url;
22
+ if (url) {
23
+ setBackUrl(new URL(url).pathname);
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }, [breadcrumbs]);
29
+
30
+ return (
31
+ <Link href={backUrl || '/'} className="hover:text-teal transition-all p-1 -mr-1">
32
+ <HiArrowLeft className="h-5 w-5 stroke-[1.25]" />
33
+ </Link>
34
+ );
35
+ }
@@ -0,0 +1,51 @@
1
+ import React from "react";
2
+
3
+ import { createHost, createSlot } from 'create-slots';
4
+ import { useClickOutside } from "../../lib";
5
+
6
+ const DropdownTitle = createSlot('button');
7
+ const DropdownContent = createSlot('form');
8
+
9
+ type Props = {
10
+ children: React.ReactNode;
11
+ onClose: Function;
12
+ active: boolean;
13
+ }
14
+
15
+ function Dropdown({ children, onClose, active }: Props) {
16
+ const ref = React.useRef<HTMLDivElement | null>(null);
17
+ const innerRef = React.useRef<HTMLDivElement | null>(null);
18
+
19
+ useClickOutside(ref, () => onClose(false));
20
+
21
+ React.useEffect(() => {
22
+ const current = ref?.current;
23
+ const innerCurrent = innerRef.current;
24
+
25
+ current?.classList.toggle('active', active)
26
+
27
+ if (!current || !innerCurrent) return;
28
+
29
+ const pos = current?.getBoundingClientRect();
30
+ innerCurrent.style.setProperty('left', `${pos?.left}px`)
31
+ }, [active, ref.current, innerRef.current]);
32
+
33
+ return createHost(children, (Slots) => {
34
+ return (
35
+ <div ref={ref} className="static inline-block">
36
+ {Slots.get(DropdownTitle)}
37
+
38
+ {active && (
39
+ <div ref={innerRef} className="absolute children z-10 flex flex-col">
40
+ {Slots.get(DropdownContent)}
41
+ </div>
42
+ )}
43
+ </div>
44
+ )
45
+ });
46
+ };
47
+
48
+ Dropdown.Title = DropdownTitle;
49
+ Dropdown.Content = DropdownContent;
50
+
51
+ export default Dropdown;
@@ -0,0 +1,318 @@
1
+ import { router } from "@inertiajs/react";
2
+ import { format, subYears } from "date-fns";
3
+ import React from "react";
4
+ import { FilterOption, FilterValue } from "../../types";
5
+ import Select from 'react-select';
6
+ import { HiChevronDown } from "react-icons/hi2";
7
+ import Dropdown from "./Dropdown";
8
+ import cn from 'classnames';
9
+ import { Button } from "../common";
10
+
11
+ export function FilterItem({ filter }: { filter: FilterOption, path?: string }) {
12
+ const [isOpen, setOpen] = React.useState<boolean>(false);
13
+ const [selected, setSelected] = React.useState<string[]>([]);
14
+
15
+ const [selectedOptions, setSelectedOptions] = React.useState<FilterValue[]>([]); // Filter type: select
16
+ const hasFilters = selected.length > 0;
17
+
18
+ // Populate values on load
19
+ React.useEffect(() => {
20
+ if (typeof window !== 'undefined') {
21
+ const search = new URLSearchParams(window.location.search);
22
+
23
+ if (filter.type === 'date') {
24
+ const fromDate = search.get(`${filter.name}[from]`);
25
+ const toDate = search.get(`${filter.name}[to]`);
26
+ if (fromDate && toDate) {
27
+ setSelected([fromDate, toDate]);
28
+ }
29
+ } else if (filter.type === 'greaterThan') {
30
+ const selectedFromUrl = search.getAll(`${filter.name}-GTE`);
31
+ if (selectedFromUrl) {
32
+ setSelected(selectedFromUrl);
33
+ }
34
+ } else if (filter.type === 'scope') {
35
+ const selectedFromUrl = search.getAll(`scope${filter.name}`);
36
+ if (selectedFromUrl) {
37
+ setSelected(selectedFromUrl);
38
+ }
39
+ } else if (filter.type === 'select') {
40
+ const selectedFromUrl = search.getAll(`${filter.name}[]`);
41
+ if (selectedFromUrl) {
42
+ setSelected(selectedFromUrl);
43
+ const selectedOptions = filter.options && filter.options.filter(item => selectedFromUrl.includes(item.value.toString()));
44
+ selectedOptions && setSelectedOptions(selectedOptions);
45
+ }
46
+ } else {
47
+ const selectedFromUrl = search.getAll(`${filter.name}[]`);
48
+ if (selectedFromUrl) {
49
+ setSelected(selectedFromUrl);
50
+ }
51
+ }
52
+ }
53
+ }, []);
54
+
55
+ function handleInput(e: React.SyntheticEvent<HTMLInputElement>) {
56
+ const { value, checked } = e.currentTarget;
57
+
58
+ if (checked) {
59
+ setSelected((curr) => [...curr, value]);
60
+ } else {
61
+ setSelected(selected.filter((f) => f !== value));
62
+ }
63
+ }
64
+
65
+ function handleDateSelect(e: React.SyntheticEvent<HTMLInputElement>) {
66
+ const { name, value } = e.currentTarget;
67
+
68
+ // First value in "selected" will be the "from", second will be the "to"
69
+ if (name.includes('from')) {
70
+ return setSelected([value]);
71
+ }
72
+
73
+ if (name.includes('to')) {
74
+ // Make sure we have a "from" value first
75
+ if (selected.length === 0) {
76
+ // We'll default to a year ago if nothing entered
77
+ return setSelected([format(subYears(new Date(), 1), 'yyyy-MM-dd'), value]);
78
+ }
79
+ return setSelected([selected[0], value]);
80
+ }
81
+
82
+ return setSelected([]);
83
+ }
84
+
85
+ /**
86
+ * Handle Select for FilterType: Select
87
+ */
88
+ const handleSelectedOptions = (options: FilterValue[] | any) => {
89
+ setSelectedOptions(options);
90
+ const selectedValues = options.map((option : FilterValue) => option.value.toString());
91
+
92
+ if (selectedValues.length > 0) {
93
+ setSelected(selectedValues);
94
+ } else {
95
+ setSelected([]);
96
+ }
97
+ };
98
+
99
+ /**
100
+ * For Filter Select
101
+ * Removes the Clear Button
102
+ */
103
+ function NullComponent() {
104
+ return null;
105
+ }
106
+
107
+ function handleSelect(e: React.SyntheticEvent<HTMLSelectElement>) {
108
+ const { value } = e.currentTarget;
109
+
110
+ if (value) {
111
+ setSelected([value]);
112
+ } else {
113
+ setSelected([]);
114
+ }
115
+ }
116
+
117
+ function handleApply(e: React.FormEvent) {
118
+ e.preventDefault();
119
+
120
+ const search = new URLSearchParams(window.location.search);
121
+
122
+ // Reset the page in the query
123
+ search.set('page', '1');
124
+
125
+ if (filter.type === 'date') {
126
+ const [from, to] = selected;
127
+
128
+ search.set(`${filter.name}[from]`, from ?? format(new Date(), 'yyyy-MM-dd'));
129
+ search.set(`${filter.name}[to]`, to ?? format(new Date(), 'yyyy-MM-dd'));
130
+ } else if (filter.type === 'greaterThan') {
131
+ search.delete(`${filter.name}-GTE`);
132
+
133
+ if (selected.length > 0) {
134
+ search.set(`${filter.name}-GTE`, selected[0]);
135
+ }
136
+ } else if (filter.type === 'scope') {
137
+ search.delete(`${filter.name}`);
138
+ if (selected.length > 0) {
139
+ search.set(`scope${filter.name}`, selected[0]);
140
+ }
141
+ } else {
142
+ // Clear this filter first
143
+ search.delete(`${filter.name}[]`);
144
+
145
+ // Apply the filters to the query string
146
+ selected.forEach((selectedValue, i) => {
147
+ // Fall back to option equality filters
148
+ search.append(`${filter.name}[]`, selectedValue);
149
+ });
150
+ }
151
+
152
+ // Fetch new data
153
+ router.get(`${window.location.pathname}?${search.toString()}`);
154
+ }
155
+
156
+ function handleClear() {
157
+ const search = new URLSearchParams(window.location.search);
158
+
159
+ // Reset the page in the query
160
+ search.set('page', '1');
161
+
162
+ // Clear this filter
163
+ if (filter.type === 'date') {
164
+ search.delete(`${filter.name}[from]`);
165
+ search.delete(`${filter.name}[to]`);
166
+ } else if (filter.type === 'greaterThan') {
167
+ search.delete(`${filter.name}-GTE`);
168
+ } else if (filter.type === 'scope') {
169
+ search.delete(`scope${filter.name}`);
170
+ } else {
171
+ search.delete(`${filter.name}[]`);
172
+ }
173
+
174
+ // Fetch new data
175
+ router.get(`${window.location.pathname}?${search.toString()}`);
176
+ }
177
+
178
+ function handleClick(force?: boolean) {
179
+ if (typeof force === 'boolean') {
180
+ return setOpen(force);
181
+ }
182
+
183
+ setOpen(!isOpen);
184
+ }
185
+
186
+ return (
187
+ <Dropdown onClose={() => setOpen(!open)} active={isOpen}>
188
+ <Dropdown.Title
189
+ onClick={() => handleClick()}
190
+ className={cn(
191
+ 'flex rounded-2xl border hover:border-teal bg-gray-600 min-w-[90px] px-2 py-1 items-center justify-between shrink-0 gap-2 text-xs text-white hover:bg-teal transition-colors',
192
+ {
193
+ '!bg-teal border-teal': hasFilters,
194
+ }
195
+ )}
196
+ >
197
+ <p className="whitespace-nowrap">{filter.label}</p>
198
+ <HiChevronDown />
199
+ </Dropdown.Title>
200
+
201
+ <Dropdown.Content
202
+ className={`mt-2 flex max-h-[350px] min-w-[200px] flex-col gap-2 rounded-md border border-gray-300 bg-white p-4 shadow-lg ${ filter.type && filter.type === 'select' ? '' : 'overflow-y-scroll'}`}
203
+ onSubmit={handleApply}
204
+ >
205
+ <div className="flex flex-col items-start gap-1">
206
+ {(!filter.type || filter.type === 'options') &&
207
+ filter.options?.map((option, i) => (
208
+ <div className="checkbox text-sm " key={`${option.label}-${i}`}>
209
+ <input
210
+ type="checkbox"
211
+ name={filter.name}
212
+ id={option.label}
213
+ value={option.value.toString()}
214
+ checked={selected.includes(option.value.toString())}
215
+ onChange={handleInput}
216
+ />
217
+ <label className="whitespace-nowrap" htmlFor={option.label}>
218
+ {option.label}
219
+ </label>
220
+ </div>
221
+ ))}
222
+ {filter.type === 'greaterThan' && filter.options && (
223
+ <label className="mb-2 w-full">
224
+ <div className="text-sm">Greater than or equal to</div>
225
+ <select
226
+ name={filter.name}
227
+ id={filter.label}
228
+ value={selected[0]}
229
+ onChange={handleSelect}
230
+ className="w-full"
231
+ >
232
+ <option value="">Select option</option>
233
+ {filter.options?.map((option) => (
234
+ <option key={option.value} value={option.value}>
235
+ {option.label}
236
+ </option>
237
+ ))}
238
+ </select>
239
+ </label>
240
+ )}
241
+ {filter.type === 'scope' && filter.options && (
242
+ <label className="mb-2 w-full">
243
+ <select
244
+ name={filter.name}
245
+ id={filter.label}
246
+ value={selected[0]}
247
+ onChange={handleSelect}
248
+ className="w-full"
249
+ >
250
+ <option value="">Select option</option>
251
+ {filter.options?.map((option) => (
252
+ <option key={option.value} value={option.value}>
253
+ {option.label}
254
+ </option>
255
+ ))}
256
+ </select>
257
+ </label>
258
+ )}
259
+ {filter.type === 'date' && (
260
+ <div className="mb-2 flex flex-col gap-4">
261
+ <label className="text-xs">
262
+ From
263
+ <input
264
+ type="date"
265
+ name={`${filter.name}[from]`}
266
+ id={`${filter.name}[from]`}
267
+ onChange={handleDateSelect}
268
+ value={selected[0] ?? ''}
269
+ />
270
+ </label>
271
+ <label className="text-xs">
272
+ To
273
+ <input
274
+ type="date"
275
+ name={`${filter.name}[to]`}
276
+ id={`${filter.name}[to]`}
277
+ onChange={handleDateSelect}
278
+ value={selected[1] ?? ''}
279
+ />
280
+ </label>
281
+ </div>
282
+ )}
283
+ {filter.type === 'select' && filter.options && (
284
+ <div className="w-full">
285
+ <Select
286
+ isMulti
287
+ options={filter.options}
288
+ className="basic-multi-select text-sm w-64 !transition-none"
289
+ classNamePrefix="select"
290
+ onChange={handleSelectedOptions}
291
+ components={{
292
+ ClearIndicator: NullComponent, // Hide the ClearIndicator (X button) -> exits filter when clicked
293
+ }}
294
+ value={selectedOptions}
295
+ theme={(theme) => ({
296
+ ...theme,
297
+ colors: {
298
+ ...theme.colors,
299
+ primary25: 'rgb(111, 199, 182)',
300
+ primary: 'rgb(111, 199, 182)',
301
+ },
302
+ })}
303
+ />
304
+ </div>
305
+ )}
306
+ </div>
307
+ <div className="flex items-center gap-2">
308
+ <Button type="button" className="!min-w-[0px] flex-grow !px-2 text-sm !transition-none" onClick={handleClear}>
309
+ Clear
310
+ </Button>
311
+ <Button type="submit" status="primary" className="!min-w-[0px] flex-grow !px-2 text-sm !transition-none">
312
+ Apply
313
+ </Button>
314
+ </div>
315
+ </Dropdown.Content>
316
+ </Dropdown>
317
+ );
318
+ }
@@ -0,0 +1,21 @@
1
+ import React from "react";
2
+ import { FilterOption } from "../../types";
3
+ import Button from "../common/Button"
4
+ import { FilterItem } from "./FilterItem";
5
+ import { router } from "@inertiajs/react";
6
+
7
+ export function FilterOptions({ filterOptions }: { filterOptions: FilterOption[] }) {
8
+ function handleClear() {
9
+ router.get(`${window.location.pathname}`);
10
+ }
11
+
12
+ return (
13
+ <div className="flex items-center">
14
+ {filterOptions.map(filter => (
15
+ <FilterItem filter={filter} key={filter.name} />
16
+ ))}
17
+ <Button onClick={handleClear}>Clear filters</Button>
18
+ </div>
19
+ );
20
+ }
21
+
@@ -0,0 +1,176 @@
1
+ import React from 'react';
2
+ import cn from 'classnames';
3
+ import { FilterOption, TPageHeaderAction } from '../../types';
4
+ import { BackButton } from './Back';
5
+ import SearchBar from './SearchBar';
6
+ import Action from './Action';
7
+ import { FilterItem } from './FilterItem';
8
+ import { HiChevronLeft, HiChevronRight, HiXMark } from 'react-icons/hi2';
9
+ import { router } from '@inertiajs/core';
10
+
11
+ interface Props {
12
+ title: string;
13
+ search?: string;
14
+ className?: string;
15
+ innerClassName?: string;
16
+ back?: boolean | string;
17
+ actions?: TPageHeaderAction[];
18
+ backText?: string;
19
+ filterOptions?: FilterOption[];
20
+ }
21
+
22
+ export default function PageHeader({
23
+ title,
24
+ search,
25
+ className = '',
26
+ innerClassName = '',
27
+ back = true,
28
+ actions = [],
29
+ filterOptions = [],
30
+ }: Props) {
31
+ const [isSearchActive, setSearchActive] = React.useState<boolean>(false);
32
+ const scrollContainerRef = React.useRef<HTMLDivElement | null>(null);
33
+
34
+ const [overScroll, setOverScroll] = React.useState<boolean>(false);
35
+
36
+ React.useEffect(() => {
37
+ const current = scrollContainerRef.current;
38
+ if (!current) return;
39
+
40
+ const listener = function() {
41
+ const activeDropdown = current.querySelector('.active');
42
+ if (!activeDropdown) return;
43
+
44
+ const childrenContainer = activeDropdown.querySelector('.children');
45
+ if (!childrenContainer) return;
46
+
47
+ const pos = activeDropdown.getBoundingClientRect();
48
+
49
+ const posChild = childrenContainer.getBoundingClientRect();
50
+ const posContainer = current.getBoundingClientRect();
51
+
52
+ // if the dropdown content moves outside the scroll container to the left, make it disappear
53
+ if (posChild.left <= posContainer.left - 50) {
54
+ // @ts-ignore
55
+ childrenContainer.style.visibility = 'hidden';
56
+ } else if (posChild.left >= posContainer.right - 50) {
57
+ // @ts-ignore
58
+ childrenContainer.style.visibility = 'hidden';
59
+ } else {
60
+ // @ts-ignore
61
+ childrenContainer.style.visibility = 'visible';
62
+ }
63
+
64
+ // @ts-ignore
65
+ childrenContainer.style.left = `${pos.left}px`;
66
+ }
67
+
68
+ const mouseListener = function(e: MouseEvent) {
69
+ const pos = current.getBoundingClientRect();
70
+ if (e.clientX > pos.left && e.clientX < pos.right && e.clientY > pos.top && e.clientY < pos.bottom) {
71
+ setOverScroll(true);
72
+ } else {
73
+ setOverScroll(false);
74
+ }
75
+ };
76
+
77
+ current.addEventListener('scroll', listener);
78
+ document.addEventListener('mousemove', mouseListener)
79
+
80
+ return () => {
81
+ current.removeEventListener('scroll', listener);
82
+ document.removeEventListener('mousemove', mouseListener)
83
+ }
84
+ }, []);
85
+
86
+ React.useEffect(() => {
87
+ const scrollListener = function(e: WheelEvent) {
88
+ if (overScroll) {
89
+ e.preventDefault();
90
+ scrollContainerRef.current?.scrollBy({ left: e.deltaY, behavior: 'auto' });
91
+ }
92
+ }
93
+
94
+ document.addEventListener('wheel', scrollListener, { passive: false });
95
+
96
+ return () => {
97
+ document.removeEventListener('wheel', scrollListener);
98
+ }
99
+ }, [overScroll])
100
+
101
+ function handleClear() {
102
+ router.get(`${window.location.pathname}`);
103
+ }
104
+
105
+ const [hasScroll, setHasScroll] = React.useState<boolean>(false);
106
+
107
+ const checkScroll = React.useCallback(() => {
108
+ const current = scrollContainerRef.current;
109
+ if (!current) return;
110
+
111
+ const pos = current.getBoundingClientRect();
112
+
113
+ setHasScroll(current.scrollWidth > (pos.width + 11));
114
+ }, [scrollContainerRef.current]);
115
+
116
+ React.useEffect(() => {
117
+ checkScroll();
118
+
119
+ window.addEventListener('resize', checkScroll)
120
+
121
+ return () => {
122
+ window.removeEventListener('resize', checkScroll);
123
+ }
124
+ }, [checkScroll]);
125
+
126
+ function clickRight() {
127
+ scrollContainerRef.current?.scrollBy({ left: 100, behavior: 'auto' });
128
+ checkScroll();
129
+ }
130
+
131
+ function clickLeft() {
132
+ scrollContainerRef.current?.scrollBy({ left: -100, behavior: 'auto' });
133
+ checkScroll();
134
+ }
135
+
136
+ return (
137
+ <section className={cn('col-span-full max-w-full rounded-lg bg-white p-2 shadow', className)}>
138
+ <div className={cn('grid grid-cols-[auto_1fr_auto] min-h-[60px] rounded gap-4 bg-gray-100 p-3', innerClassName)}>
139
+ <div className="mr-auto flex items-center gap-4">
140
+ {back && <BackButton back={back} />}
141
+
142
+ {search && <SearchBar search={search} active={isSearchActive} onActive={setSearchActive} placeholder={`Search ${title}...`} />}
143
+
144
+ {!isSearchActive && <h1 className="text-base font-bold">{title}</h1>}
145
+ </div>
146
+
147
+ <div className="flex ml-auto items-center max-w-sm md:max-w-md lg:max-w-lg xl:max-w-2xl gap-2 h-full min-w-0">
148
+ {filterOptions.length > 0 && (
149
+ <>
150
+ <button onClick={handleClear} className="p-1.5 hover:text-red-500"><HiXMark className="w-5 h-5 stroke-[1.25]" /></button>
151
+
152
+ {hasScroll && <HiChevronLeft onClick={clickLeft} className="w-5 h-5 text-gray-400 stroke-[1.25]" />}
153
+ <div ref={scrollContainerRef} className="flex items-center h-full w-full gap-3 overflow-x-scroll hide-scroll">
154
+ {filterOptions.map(filter => (
155
+ <FilterItem key={filter.name} filter={filter} />
156
+ ))}
157
+ </div>
158
+ {hasScroll && <HiChevronRight onClick={clickRight} className="w-5 h-5 text-gray-400 stroke-[1.25]" />}
159
+ </>
160
+ )}
161
+ </div>
162
+
163
+ {actions.length > 0 && (
164
+ <div className="flex items-center gap-3">
165
+ {filterOptions.length > 0 && <div className="w-[1px] h-full bg-gray-300" />}
166
+ <nav className="flex items-center gap-2">
167
+ {actions.map((action) => (
168
+ <Action key={`${action.title}-${action.as}-${action.href}`} {...action} />
169
+ ))}
170
+ </nav>
171
+ </div>
172
+ )}
173
+ </div>
174
+ </section>
175
+ );
176
+ }
@@ -0,0 +1,105 @@
1
+ import { router } from "@inertiajs/react";
2
+ import React from "react";
3
+ import { HiMagnifyingGlass, HiXMark } from "react-icons/hi2";
4
+ import { useClickOutside } from "../../lib";
5
+
6
+
7
+ export default function SearchBar({ search, active, onActive, placeholder }: { search?: string, active: boolean, onActive: Function, placeholder?: string }) {
8
+ const [query, setQuery] = React.useState<string>('');
9
+
10
+ const searchRef = React.useRef<HTMLDivElement | null>(null);
11
+
12
+ /**
13
+ * Populate search input on load
14
+ */
15
+ React.useEffect(() => {
16
+ if (typeof window !== 'undefined') {
17
+ // Populate search
18
+ const search = new URLSearchParams(window.location.search);
19
+
20
+ const q = search.get('search') as string;
21
+
22
+ if (q) {
23
+ setQuery(q);
24
+ onActive(true);
25
+ }
26
+ }
27
+ }, []);
28
+
29
+ /**
30
+ * Minimise the search bar on click outside if there's no search query
31
+ */
32
+ useClickOutside(searchRef, () => {
33
+ if (!query) {
34
+ onActive(false);
35
+ }
36
+ });
37
+
38
+ /**
39
+ * Handle the search function
40
+ */
41
+ function handleSearch(e?: React.FormEvent, queryOverride?: string) {
42
+ e?.preventDefault();
43
+
44
+ if (typeof search !== 'string') return;
45
+
46
+ let path: string = search;
47
+ if (path[0] !== '/') {
48
+ path = `/${path}`;
49
+ }
50
+
51
+ const params = new URLSearchParams(window?.location.search);
52
+ params.set('search', queryOverride ?? query);
53
+ params.set('page', '1');
54
+
55
+ router.get(`${path}?${params.toString()}`);
56
+ }
57
+
58
+ return (
59
+ <div ref={searchRef} className="">
60
+ {!active && (
61
+ <button
62
+ title="Open Search Bar"
63
+ type="button"
64
+ onClick={() => onActive(true)}
65
+ className="flex items-center justify-center rounded-full hover:text-teal p-1 transition-colors hover:bg-gray-100"
66
+ >
67
+ <HiMagnifyingGlass className="h-5 w-5 stroke-[1.25]" />
68
+ </button>
69
+ )}
70
+ {active && (
71
+ <form
72
+ onSubmit={handleSearch}
73
+ className="group flex items-center gap-2 rounded-full bg-white pr-4 pl-1 shadow transition-all focus-within:outline-none focus-within:ring-1 focus-within:ring-teal"
74
+ >
75
+ <input
76
+ type="text"
77
+ name="search"
78
+ id="search"
79
+ value={query}
80
+ onChange={(e) => setQuery(e.currentTarget.value || '')}
81
+ className="!focus:border-0 !border-0 !bg-transparent !shadow-none !ring-opacity-0 !transition-none placeholder:text-gray-400 !py-1.5 text-sm"
82
+ autoFocus
83
+ placeholder={placeholder}
84
+ />
85
+ {query && (
86
+ <button
87
+ type="button"
88
+ title="Clear Search"
89
+ onClick={() => {
90
+ setQuery('');
91
+ handleSearch(undefined, '');
92
+ }}
93
+ >
94
+ <HiXMark className="h-5 w-5" />
95
+ </button>
96
+ )}
97
+ <button type="submit">
98
+ <HiMagnifyingGlass className="h-5 w-5" />
99
+ </button>
100
+ </form>
101
+ )}
102
+ </div>
103
+ );
104
+ }
105
+
@@ -0,0 +1 @@
1
+ export { default as PageHeader } from './PageHeader';
@@ -0,0 +1,24 @@
1
+ import { Page, PageProps } from '@inertiajs/core';
2
+ import { usePage as inertiaUsePage } from '@inertiajs/react';
3
+ import { AuthContext, Breadcrumb, Environment } from '../types';
4
+
5
+ /**
6
+ * Add custom props here, that are defined in `app/Http/Middleware/HandleInertiaRequests.php`
7
+ */
8
+ type CustomPageProps = {
9
+ env: Environment;
10
+ breadcrumbs: Breadcrumb[];
11
+ links: Record<string, string>;
12
+ access_token?: string;
13
+ impersonate_id?: number;
14
+ csrf_token?: string;
15
+ auth: AuthContext;
16
+ } & PageProps;
17
+
18
+ /**
19
+ * A wrapper around Inertia's usePage function that gives better type-safety
20
+ */
21
+ export function usePage<T>(): Page<CustomPageProps & T> {
22
+ // @ts-ignore
23
+ return inertiaUsePage();
24
+ }
@@ -6,3 +6,36 @@ export interface Toast {
6
6
  status?: 'success' | 'error' | 'default' | 'warning';
7
7
  timeout?: number;
8
8
  }
9
+
10
+ export interface FilterOption {
11
+ label: string;
12
+ name: string;
13
+ options?: FilterValue[];
14
+ type?: 'options' | 'date' | 'greaterThan' | 'scope' | 'select';
15
+ }
16
+
17
+ export interface FilterValue {
18
+ label: string;
19
+ value: string | number;
20
+ }
21
+
22
+ export interface Filter {
23
+ [key: string]: (string | number)[];
24
+ }
25
+
26
+ export type TPageHeaderAction = {
27
+ title: string;
28
+ as?: 'button' | 'a' | 'Link';
29
+ href?: string;
30
+ target?: '_self' | '_blank';
31
+ onClick?: Function;
32
+ type?: 'button' | 'submit';
33
+ };
34
+
35
+ export type Environment = 'production' | 'staging' | 'testing' | 'local';
36
+
37
+ export type Breadcrumb = {
38
+ current?: boolean;
39
+ title: string;
40
+ url?: string;
41
+ };