notionsoft-ui 1.0.16 → 1.0.17

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notionsoft-ui",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "description": "A React UI component installer (shadcn-style). Installs components directly into your project.",
5
5
  "bin": {
6
6
  "notionsoft-ui": "./cli/index.cjs"
@@ -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, CheckCheck, Eraser, ListFilter, X } from "lucide-react";
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 interface MultiSelectInputProps<T = any>
20
- extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onSelect"> {
21
- fetch: (
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
- const data = await fetch(
116
- debouncedValue,
117
- filtersState,
118
- maxFetch && !isNaN(Number(maxFetch)) ? Number(maxFetch) : undefined
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 SearchInputProps<T = { id: string; name: string }>
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
- const data = await fetch(
94
- debouncedValue,
95
- filtersState,
96
- maxFetch && !isNaN(Number(maxFetch)) ? Number(maxFetch) : undefined
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
- if (
107
- debouncedValue ||
108
- debouncedValue === "" ||
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
+ }