notionsoft-ui 1.0.16 → 1.0.18
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.
package/package.json
CHANGED
|
@@ -57,7 +57,6 @@ const fetchUsers = async (
|
|
|
57
57
|
) => {
|
|
58
58
|
let result = mockUsers;
|
|
59
59
|
|
|
60
|
-
// Apply filters
|
|
61
60
|
if (filters) {
|
|
62
61
|
result = result.filter((user) =>
|
|
63
62
|
Object.entries(filters).every(([key, value]) =>
|
|
@@ -66,7 +65,6 @@ const fetchUsers = async (
|
|
|
66
65
|
);
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
// Apply search
|
|
70
68
|
if (query) {
|
|
71
69
|
const q = query.toLowerCase();
|
|
72
70
|
result = result.filter(
|
|
@@ -75,10 +73,8 @@ const fetchUsers = async (
|
|
|
75
73
|
);
|
|
76
74
|
}
|
|
77
75
|
|
|
78
|
-
// Apply maxFetch
|
|
79
76
|
if (maxFetch) result = result.slice(0, maxFetch);
|
|
80
77
|
|
|
81
|
-
// Simulate network delay
|
|
82
78
|
await new Promise((r) => setTimeout(r, 300));
|
|
83
79
|
|
|
84
80
|
return result;
|
|
@@ -146,3 +142,25 @@ SingleSelection.args = {
|
|
|
146
142
|
clearFilters: "Clear Filters",
|
|
147
143
|
},
|
|
148
144
|
};
|
|
145
|
+
|
|
146
|
+
// ------------------ API Config story ------------------
|
|
147
|
+
export const APIConfigExample = Template.bind({});
|
|
148
|
+
APIConfigExample.args = {
|
|
149
|
+
apiConfig: {
|
|
150
|
+
url: "https://jsonplaceholder.typicode.com/users",
|
|
151
|
+
headers: { "Content-Type": "application/json" },
|
|
152
|
+
},
|
|
153
|
+
selectionMode: "multiple",
|
|
154
|
+
searchBy: ["name", "email"],
|
|
155
|
+
filters: [
|
|
156
|
+
{ key: "active", name: "Active" },
|
|
157
|
+
{ key: "admin", name: "Admin" },
|
|
158
|
+
],
|
|
159
|
+
itemKey: "id", // JSONPlaceholder uses `id` as key
|
|
160
|
+
text: {
|
|
161
|
+
fetch: "Fetching users from API...",
|
|
162
|
+
notItem: "No users found",
|
|
163
|
+
maxRecord: "Max results",
|
|
164
|
+
clearFilters: "Clear Filters",
|
|
165
|
+
},
|
|
166
|
+
};
|
|
@@ -6,23 +6,33 @@ import React, {
|
|
|
6
6
|
useState,
|
|
7
7
|
} from "react";
|
|
8
8
|
import { createPortal } from "react-dom";
|
|
9
|
-
import { cn } from "../../utils/cn";
|
|
9
|
+
import { buildNestedFiltersQuery, cn, useDebounce } from "../../utils/cn";
|
|
10
10
|
import Input from "../input";
|
|
11
|
-
import { Check,
|
|
11
|
+
import { Check, Eraser, ListFilter, X } from "lucide-react";
|
|
12
12
|
import CircleLoader from "../circle-loader";
|
|
13
13
|
|
|
14
14
|
export interface FilterItem {
|
|
15
15
|
key: string;
|
|
16
16
|
name: string;
|
|
17
17
|
}
|
|
18
|
+
interface FetchConfig {
|
|
19
|
+
url: string;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
params?: string;
|
|
22
|
+
}
|
|
18
23
|
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
export type MultiSelectInputProps<T = any> = Omit<
|
|
25
|
+
React.InputHTMLAttributes<HTMLInputElement>,
|
|
26
|
+
"onSelect"
|
|
27
|
+
> & {
|
|
28
|
+
// Either `fetch` function OR `apiConfig` must be provided
|
|
29
|
+
fetch?: (
|
|
22
30
|
value: string,
|
|
23
31
|
filters?: Record<string, boolean>,
|
|
24
32
|
maxFetch?: number
|
|
25
33
|
) => Promise<T[]>;
|
|
34
|
+
apiConfig?: FetchConfig;
|
|
35
|
+
|
|
26
36
|
renderItem?: (item: T, selected?: boolean) => React.ReactNode;
|
|
27
37
|
filters?: FilterItem[];
|
|
28
38
|
onFiltersChange?: (filtersState: Record<string, boolean>) => void;
|
|
@@ -41,7 +51,17 @@ export interface MultiSelectInputProps<T = any>
|
|
|
41
51
|
searchBy?: keyof T | (keyof T)[];
|
|
42
52
|
itemKey?: keyof T;
|
|
43
53
|
STORAGE_KEY?: string;
|
|
44
|
-
}
|
|
54
|
+
} & (
|
|
55
|
+
| {
|
|
56
|
+
fetch: (
|
|
57
|
+
value: string,
|
|
58
|
+
filters?: Record<string, boolean>,
|
|
59
|
+
maxFetch?: number
|
|
60
|
+
) => Promise<T[]>;
|
|
61
|
+
apiConfig?: any;
|
|
62
|
+
}
|
|
63
|
+
| { apiConfig: FetchConfig; fetch?: any }
|
|
64
|
+
);
|
|
45
65
|
|
|
46
66
|
function MultiSelectInputInner<T = any>(
|
|
47
67
|
{
|
|
@@ -64,6 +84,7 @@ function MultiSelectInputInner<T = any>(
|
|
|
64
84
|
onItemsSelect,
|
|
65
85
|
searchBy,
|
|
66
86
|
itemKey,
|
|
87
|
+
apiConfig,
|
|
67
88
|
...props
|
|
68
89
|
}: MultiSelectInputProps<T>,
|
|
69
90
|
ref: React.Ref<HTMLInputElement>
|
|
@@ -107,25 +128,55 @@ function MultiSelectInputInner<T = any>(
|
|
|
107
128
|
|
|
108
129
|
const debouncedValue = useDebounce(inputValue, debounceValue);
|
|
109
130
|
|
|
110
|
-
// Fetch items
|
|
111
131
|
useEffect(() => {
|
|
112
132
|
const get = async () => {
|
|
113
133
|
setIsFetching(true);
|
|
114
134
|
try {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
135
|
+
let data: T[] = [];
|
|
136
|
+
|
|
137
|
+
if (fetch) {
|
|
138
|
+
// User-provided fetch function
|
|
139
|
+
data = await fetch(
|
|
140
|
+
debouncedValue,
|
|
141
|
+
filtersState,
|
|
142
|
+
maxFetch && !isNaN(Number(maxFetch)) ? Number(maxFetch) : undefined
|
|
143
|
+
);
|
|
144
|
+
} else if (apiConfig) {
|
|
145
|
+
// Only include active filters
|
|
146
|
+
const activeFilters = Object.fromEntries(
|
|
147
|
+
Object.entries(filtersState).filter(([_, v]) => v)
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Build nested filters query
|
|
151
|
+
const filtersQuery = buildNestedFiltersQuery(activeFilters);
|
|
152
|
+
|
|
153
|
+
const combinedParams = new URLSearchParams({
|
|
154
|
+
q: debouncedValue,
|
|
155
|
+
maxFetch: maxFetch?.toString() ?? "",
|
|
156
|
+
...apiConfig.params,
|
|
157
|
+
}).toString();
|
|
158
|
+
|
|
159
|
+
const url = `${apiConfig.url}?${combinedParams}${
|
|
160
|
+
filtersQuery ? "&" + filtersQuery : ""
|
|
161
|
+
}`;
|
|
162
|
+
|
|
163
|
+
const res = await window.fetch(url, {
|
|
164
|
+
headers: apiConfig.headers,
|
|
165
|
+
});
|
|
166
|
+
data = await res.json();
|
|
167
|
+
}
|
|
168
|
+
|
|
120
169
|
setItems(data);
|
|
121
|
-
} catch {
|
|
170
|
+
} catch (err: any) {
|
|
171
|
+
console.error(err);
|
|
122
172
|
setItems([]);
|
|
123
173
|
} finally {
|
|
124
174
|
setIsFetching(false);
|
|
125
175
|
}
|
|
126
176
|
};
|
|
177
|
+
|
|
127
178
|
get();
|
|
128
|
-
}, [debouncedValue, fetch, filtersState, maxFetch]);
|
|
179
|
+
}, [debouncedValue, fetch, apiConfig, filtersState, maxFetch]);
|
|
129
180
|
|
|
130
181
|
// Update dropdown position
|
|
131
182
|
const updatePosition = () => {
|
|
@@ -301,16 +352,6 @@ const MultiSelectInputForward = React.forwardRef(MultiSelectInputInner) as <
|
|
|
301
352
|
|
|
302
353
|
export default MultiSelectInputForward;
|
|
303
354
|
|
|
304
|
-
// ---------------- Debounce Hook ----------------
|
|
305
|
-
export function useDebounce<T>(value: T, delay?: number): T {
|
|
306
|
-
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
307
|
-
useEffect(() => {
|
|
308
|
-
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
|
|
309
|
-
return () => clearTimeout(timer);
|
|
310
|
-
}, [value, delay]);
|
|
311
|
-
return debouncedValue;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
355
|
// ---------------- Dropdown ----------------
|
|
315
356
|
const Dropdown = <T,>(
|
|
316
357
|
position: { top: number; left: number; width: number },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
|
-
import { cn } from "../../utils/cn";
|
|
3
|
+
import { buildNestedFiltersQuery, cn, useDebounce } from "../../utils/cn";
|
|
4
4
|
import Input from "../input";
|
|
5
5
|
import { Eraser, ListFilter, X } from "lucide-react";
|
|
6
6
|
import CircleLoader from "../circle-loader";
|
|
@@ -11,15 +11,14 @@ export interface FilterItem {
|
|
|
11
11
|
key: string;
|
|
12
12
|
name: string;
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
interface ApiConfig {
|
|
15
|
+
url: string;
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
params?: Record<string, any>;
|
|
18
|
+
}
|
|
15
19
|
// Generic Props
|
|
16
|
-
export interface
|
|
17
|
-
extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
18
|
-
fetch: (
|
|
19
|
-
value: string,
|
|
20
|
-
filters?: Record<string, boolean>,
|
|
21
|
-
maxFetch?: number
|
|
22
|
-
) => Promise<T[]>;
|
|
20
|
+
export interface BaseSearchInputProps<T = { id: string; name: string }>
|
|
21
|
+
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onSelect"> {
|
|
23
22
|
renderItem?: (item: T) => React.ReactNode;
|
|
24
23
|
itemOnClick?: (item: T) => void;
|
|
25
24
|
filters?: FilterItem[];
|
|
@@ -36,6 +35,27 @@ export interface SearchInputProps<T = { id: string; name: string }>
|
|
|
36
35
|
STORAGE_KEY?: string;
|
|
37
36
|
}
|
|
38
37
|
|
|
38
|
+
// Either user provides `fetch` function...
|
|
39
|
+
export interface FetchProps<T> extends BaseSearchInputProps<T> {
|
|
40
|
+
fetch: (
|
|
41
|
+
value: string,
|
|
42
|
+
filters?: Record<string, boolean>,
|
|
43
|
+
maxFetch?: number
|
|
44
|
+
) => Promise<T[]>;
|
|
45
|
+
apiConfig?: never;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ...or `apiConfig` object
|
|
49
|
+
export interface ApiConfigProps<T> extends BaseSearchInputProps<T> {
|
|
50
|
+
fetch?: never;
|
|
51
|
+
apiConfig: ApiConfig;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The final props type
|
|
55
|
+
export type SearchInputProps<T = { id: string; name: string }> =
|
|
56
|
+
| FetchProps<T>
|
|
57
|
+
| ApiConfigProps<T>;
|
|
58
|
+
|
|
39
59
|
// ✅ Generic forwardRef wrapper
|
|
40
60
|
function SearchInputInner<T = { id: string; name: string }>(
|
|
41
61
|
{
|
|
@@ -53,6 +73,7 @@ function SearchInputInner<T = { id: string; name: string }>(
|
|
|
53
73
|
},
|
|
54
74
|
endContent,
|
|
55
75
|
STORAGE_KEY = "FILTER_STORAGE_KEY",
|
|
76
|
+
apiConfig,
|
|
56
77
|
itemOnClick,
|
|
57
78
|
...props
|
|
58
79
|
}: SearchInputProps<T>,
|
|
@@ -90,26 +111,51 @@ function SearchInputInner<T = { id: string; name: string }>(
|
|
|
90
111
|
const get = async () => {
|
|
91
112
|
setIsFetching(true);
|
|
92
113
|
try {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
114
|
+
let data: T[] = [];
|
|
115
|
+
|
|
116
|
+
if (fetch) {
|
|
117
|
+
// User-provided fetch function
|
|
118
|
+
data = await fetch(
|
|
119
|
+
debouncedValue,
|
|
120
|
+
filtersState,
|
|
121
|
+
maxFetch && !isNaN(Number(maxFetch)) ? Number(maxFetch) : undefined
|
|
122
|
+
);
|
|
123
|
+
} else if (apiConfig) {
|
|
124
|
+
// Only include active filters
|
|
125
|
+
const activeFilters = Object.fromEntries(
|
|
126
|
+
Object.entries(filtersState).filter(([_, v]) => v)
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Build nested filters query
|
|
130
|
+
const filtersQuery = buildNestedFiltersQuery(activeFilters);
|
|
131
|
+
|
|
132
|
+
const combinedParams = new URLSearchParams({
|
|
133
|
+
q: debouncedValue,
|
|
134
|
+
maxFetch: maxFetch?.toString() ?? "",
|
|
135
|
+
...apiConfig.params,
|
|
136
|
+
}).toString();
|
|
137
|
+
|
|
138
|
+
const url = `${apiConfig.url}?${combinedParams}${
|
|
139
|
+
filtersQuery ? "&" + filtersQuery : ""
|
|
140
|
+
}`;
|
|
141
|
+
|
|
142
|
+
const res = await window.fetch(url, {
|
|
143
|
+
headers: apiConfig.headers,
|
|
144
|
+
});
|
|
145
|
+
data = await res.json();
|
|
146
|
+
}
|
|
147
|
+
|
|
98
148
|
setItems(data);
|
|
99
|
-
} catch (err) {
|
|
100
|
-
setItems([]);
|
|
149
|
+
} catch (err: any) {
|
|
101
150
|
console.error(err);
|
|
151
|
+
setItems([]);
|
|
102
152
|
} finally {
|
|
103
153
|
setIsFetching(false);
|
|
104
154
|
}
|
|
105
155
|
};
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
Object.values(filtersState).some((v) => v)
|
|
110
|
-
)
|
|
111
|
-
get();
|
|
112
|
-
}, [debouncedValue, fetch, filtersState, maxFetch]);
|
|
156
|
+
|
|
157
|
+
get();
|
|
158
|
+
}, [debouncedValue, fetch, apiConfig, filtersState, maxFetch]);
|
|
113
159
|
|
|
114
160
|
const updatePosition = () => {
|
|
115
161
|
const el = containerRef.current;
|
|
@@ -246,16 +292,6 @@ const SearchInputForward = React.forwardRef(SearchInputInner) as <
|
|
|
246
292
|
|
|
247
293
|
export default SearchInputForward;
|
|
248
294
|
|
|
249
|
-
/* Debounce Hook */
|
|
250
|
-
export function useDebounce<T>(value: T, delay?: number): T {
|
|
251
|
-
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
252
|
-
useEffect(() => {
|
|
253
|
-
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
|
|
254
|
-
return () => clearTimeout(timer);
|
|
255
|
-
}, [value, delay]);
|
|
256
|
-
return debouncedValue;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
295
|
/* Dropdown */
|
|
260
296
|
const Dropdown = <T,>(
|
|
261
297
|
position: { top: number; left: number; width: number },
|
package/src/utils/cn.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { clsx } from "clsx";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
2
3
|
import { twMerge } from "tailwind-merge";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -7,3 +8,28 @@ import { twMerge } from "tailwind-merge";
|
|
|
7
8
|
export function cn(...inputs: Parameters<typeof clsx>) {
|
|
8
9
|
return twMerge(clsx(...inputs));
|
|
9
10
|
}
|
|
11
|
+
export function buildNestedFiltersQuery(filters: Record<string, any>): string {
|
|
12
|
+
const params = new URLSearchParams();
|
|
13
|
+
|
|
14
|
+
function recurse(obj: Record<string, any>, prefix: string) {
|
|
15
|
+
Object.entries(obj).forEach(([key, value]) => {
|
|
16
|
+
const newKey = `${prefix}[${key}]`;
|
|
17
|
+
if (value && typeof value === "object") {
|
|
18
|
+
recurse(value, newKey);
|
|
19
|
+
} else if (value !== undefined && value !== null) {
|
|
20
|
+
params.append(newKey, value.toString());
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
recurse(filters, "filters");
|
|
26
|
+
return params.toString();
|
|
27
|
+
}
|
|
28
|
+
export function useDebounce<T>(value: T, delay?: number): T {
|
|
29
|
+
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
|
|
32
|
+
return () => clearTimeout(timer);
|
|
33
|
+
}, [value, delay]);
|
|
34
|
+
return debouncedValue;
|
|
35
|
+
}
|