terrazzo 0.7.6 → 0.7.8
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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/app/controllers/terrazzo/application_controller.rb +1 -1
- data/lib/generators/terrazzo/install/install_generator.rb +2 -0
- data/lib/generators/terrazzo/views/templates/components/AssociationSelect.jsx +191 -146
- data/lib/terrazzo/field/associative.rb +4 -7
- data/lib/terrazzo/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7116e86ccb7f5bd1ba8309e966913c53746f4a6f7e7ff6461194c084b236697f
|
|
4
|
+
data.tar.gz: b1ff3fb03ce897c082df6977e919bedf667a9d0867e402ea7d720572d4bb5fb9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2dccc34e6589dee6e9a29ac78df787f405c35b841ddf4168fce78fc66afdc2616fe19a657cc751434f15ec703042452f7d1940b58fc6b86e8054a422a130e1b3
|
|
7
|
+
data.tar.gz: ff55728ffbe3d61cbafc4d6b41742363fe4efc3c9b15e17f5b68570fdec9def720018635450d931a7f247eca4ab2125e03248929d8f5078a3d878f3d9486b695
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Terrazzo
|
|
2
2
|
|
|
3
|
-
Association forms load selected values first. `HasMany` and `BelongsTo` use
|
|
3
|
+
Association forms load selected values first. `HasMany` and `BelongsTo` use typeahead search with at most 10 matches. Upgrade the gem and npm package together. Custom association form components must use the new option query contract; see [custom fields](docs/customizing-fields.md).
|
|
4
4
|
|
|
5
5
|
A drop-in admin panel for Rails apps. Uses the [Administrate](https://github.com/thoughtbot/administrate) dashboard DSL with a React SPA frontend powered by [Superglue](https://github.com/thoughtbot/superglue).
|
|
6
6
|
|
|
@@ -33,7 +33,7 @@ npm install @radix-ui/react-avatar@1.1.11 @radix-ui/react-dialog@1.1.15 \
|
|
|
33
33
|
@radix-ui/react-popover@1.1.15 @radix-ui/react-select@2.2.6 \
|
|
34
34
|
@radix-ui/react-separator@1.1.8 @radix-ui/react-slot@1.2.4 \
|
|
35
35
|
@radix-ui/react-tooltip@1.2.8 class-variance-authority@0.7.1 \
|
|
36
|
-
lucide-react@0.344.0 tailwindcss@4.2.1
|
|
36
|
+
cmdk@1.1.1 lucide-react@0.344.0 tailwindcss@4.2.1
|
|
37
37
|
npm install --save-dev @tailwindcss/cli@4.2.1 # if your app does not already compile Tailwind
|
|
38
38
|
|
|
39
39
|
# Install Terrazzo — generates admin namespace, app barrels,
|
|
@@ -282,7 +282,7 @@ module Terrazzo
|
|
|
282
282
|
return head :not_found unless field.association_options?
|
|
283
283
|
|
|
284
284
|
response.headers["Cache-Control"] = "no-store"
|
|
285
|
-
render json: field.association_options(search: params[:search]
|
|
285
|
+
render json: field.association_options(search: params[:search])
|
|
286
286
|
end
|
|
287
287
|
|
|
288
288
|
def resolver
|
|
@@ -32,6 +32,7 @@ module Terrazzo
|
|
|
32
32
|
@radix-ui/react-slot
|
|
33
33
|
@radix-ui/react-tooltip
|
|
34
34
|
class-variance-authority
|
|
35
|
+
cmdk
|
|
35
36
|
lucide-react
|
|
36
37
|
] + [TAILWIND_PACKAGE_NAME]
|
|
37
38
|
).freeze
|
|
@@ -52,6 +53,7 @@ module Terrazzo
|
|
|
52
53
|
"@radix-ui/react-slot" => "1.0.0",
|
|
53
54
|
"@radix-ui/react-tooltip" => "1.0.0",
|
|
54
55
|
"class-variance-authority" => "0.7.0",
|
|
56
|
+
"cmdk" => "1.1.1",
|
|
55
57
|
"lucide-react" => "0.300.0",
|
|
56
58
|
TAILWIND_PACKAGE_NAME => TAILWIND_MINIMUM_VERSION,
|
|
57
59
|
}.freeze
|
|
@@ -1,59 +1,53 @@
|
|
|
1
|
+
import { Command } from "cmdk";
|
|
2
|
+
import { Check, ChevronsUpDown, Loader2, Search, X } from "lucide-react";
|
|
1
3
|
import React, { useEffect, useId, useState } from "react";
|
|
4
|
+
import { cn } from "terrazzo";
|
|
2
5
|
import { Label, Popover, PopoverContent, PopoverTrigger } from "./ui";
|
|
3
6
|
|
|
4
|
-
export function AssociationSelect({
|
|
5
|
-
value,
|
|
6
|
-
options,
|
|
7
|
-
input,
|
|
8
|
-
label,
|
|
9
|
-
required,
|
|
10
|
-
multiple = false,
|
|
11
|
-
onChange,
|
|
12
|
-
}) {
|
|
7
|
+
export function AssociationSelect({ value, options, input, label, required, multiple = false, onChange }) {
|
|
13
8
|
const popupId = useId();
|
|
9
|
+
const searchRef = React.useRef(null);
|
|
14
10
|
const [selectedIds, setSelectedIds] = useState(() =>
|
|
15
|
-
(multiple
|
|
16
|
-
? (value ?? [])
|
|
17
|
-
: value == null || value === ""
|
|
18
|
-
? []
|
|
19
|
-
: [value]
|
|
20
|
-
).map(String),
|
|
21
|
-
);
|
|
22
|
-
const [selectedOptions, setSelectedOptions] = useState(
|
|
23
|
-
options.resourceOptions,
|
|
11
|
+
(multiple ? (value ?? []) : value == null || value === "" ? [] : [value]).map(String)
|
|
24
12
|
);
|
|
13
|
+
const [selectedOptions, setSelectedOptions] = useState(options.resourceOptions);
|
|
25
14
|
const [open, setOpen] = useState(false);
|
|
26
15
|
const [search, setSearch] = useState("");
|
|
27
|
-
const [
|
|
28
|
-
const [
|
|
16
|
+
const [result, setResult] = useState(null);
|
|
17
|
+
const [activeResultId, setActiveResultId] = useState("");
|
|
29
18
|
const [loading, setLoading] = useState(false);
|
|
30
19
|
const [error, setError] = useState("");
|
|
31
20
|
const [retry, setRetry] = useState(0);
|
|
21
|
+
const selected = selectedOptions.filter((option) => selectedIds.includes(option[1]));
|
|
22
|
+
const placeholder = `Select ${label?.toLowerCase() ?? (multiple ? "items" : "an option")}…`;
|
|
32
23
|
|
|
33
24
|
useEffect(() => {
|
|
34
25
|
if (!open) return;
|
|
35
26
|
const controller = new window.AbortController();
|
|
36
27
|
let active = true;
|
|
37
28
|
setLoading(true);
|
|
38
|
-
setError("");
|
|
39
29
|
const timer = setTimeout(async () => {
|
|
40
30
|
try {
|
|
41
31
|
const url = new URL(options.optionsUrl, window.location.origin);
|
|
42
32
|
url.searchParams.set("search", search);
|
|
43
|
-
url.searchParams.set("page", String(page));
|
|
44
33
|
const response = await window.fetch(url, {
|
|
45
34
|
headers: { Accept: "application/json" },
|
|
46
35
|
signal: controller.signal,
|
|
47
|
-
credentials: "same-origin"
|
|
36
|
+
credentials: "same-origin"
|
|
48
37
|
});
|
|
49
38
|
if (!response.ok) throw new Error("Could not load options.");
|
|
50
39
|
const data = await response.json();
|
|
51
|
-
if (active)
|
|
40
|
+
if (active) {
|
|
41
|
+
setResult(data);
|
|
42
|
+
setActiveResultId(data.resourceOptions[0]?.[1] ?? "");
|
|
43
|
+
setError("");
|
|
44
|
+
}
|
|
52
45
|
} catch (failure) {
|
|
53
|
-
if (active && failure.name !== "AbortError")
|
|
54
|
-
setError("Could not load options.");
|
|
46
|
+
if (active && failure.name !== "AbortError") setError("Could not load options.");
|
|
55
47
|
} finally {
|
|
56
|
-
if (active)
|
|
48
|
+
if (active) {
|
|
49
|
+
setLoading(false);
|
|
50
|
+
}
|
|
57
51
|
}
|
|
58
52
|
}, 200);
|
|
59
53
|
return () => {
|
|
@@ -61,7 +55,7 @@ export function AssociationSelect({
|
|
|
61
55
|
clearTimeout(timer);
|
|
62
56
|
controller.abort();
|
|
63
57
|
};
|
|
64
|
-
}, [open, search,
|
|
58
|
+
}, [open, search, options.optionsUrl, retry]);
|
|
65
59
|
|
|
66
60
|
const select = (option) => {
|
|
67
61
|
const id = option[1];
|
|
@@ -70,10 +64,9 @@ export function AssociationSelect({
|
|
|
70
64
|
? selectedIds.filter((item) => item !== id)
|
|
71
65
|
: [...selectedIds, id]
|
|
72
66
|
: [id];
|
|
73
|
-
const nextOptions = [
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
].filter((item) => nextIds.includes(item[1]));
|
|
67
|
+
const nextOptions = [...selectedOptions.filter((item) => item[1] !== id), option].filter((item) =>
|
|
68
|
+
nextIds.includes(item[1])
|
|
69
|
+
);
|
|
77
70
|
setSelectedIds(nextIds);
|
|
78
71
|
setSelectedOptions(nextOptions);
|
|
79
72
|
onChange?.(multiple ? nextIds : id, option);
|
|
@@ -87,140 +80,192 @@ export function AssociationSelect({
|
|
|
87
80
|
onChange?.(multiple ? nextIds : "", null);
|
|
88
81
|
};
|
|
89
82
|
|
|
83
|
+
const changeOpen = (next) => {
|
|
84
|
+
setOpen(next);
|
|
85
|
+
if (next) {
|
|
86
|
+
setLoading(true);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
90
|
return (
|
|
91
91
|
<div className="space-y-2">
|
|
92
92
|
{label && (
|
|
93
93
|
<Label htmlFor={input?.id}>
|
|
94
94
|
{label}
|
|
95
|
-
{required && "
|
|
95
|
+
{required && <span className="text-destructive ml-1">*</span>}
|
|
96
96
|
</Label>
|
|
97
97
|
)}
|
|
98
98
|
{input?.name && <input type="hidden" name={input.name} value="" />}
|
|
99
|
-
{input?.name &&
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.filter((option) => selectedIds.includes(option[1]))
|
|
107
|
-
.map(([display, id]) => (
|
|
108
|
-
<button
|
|
109
|
-
key={id}
|
|
110
|
-
type="button"
|
|
111
|
-
className="rounded bg-muted px-2 py-1 text-sm"
|
|
112
|
-
aria-label={`Remove ${display}`}
|
|
113
|
-
onClick={() => remove(id)}
|
|
114
|
-
>
|
|
115
|
-
{display} ×
|
|
116
|
-
</button>
|
|
117
|
-
))}
|
|
118
|
-
</div>
|
|
119
|
-
)}
|
|
120
|
-
<Popover
|
|
121
|
-
open={open}
|
|
122
|
-
onOpenChange={(next) => {
|
|
123
|
-
setOpen(next);
|
|
124
|
-
if (next) setLoading(true);
|
|
125
|
-
}}
|
|
126
|
-
>
|
|
127
|
-
<PopoverTrigger asChild>
|
|
128
|
-
<button
|
|
129
|
-
type="button"
|
|
130
|
-
id={input?.id}
|
|
131
|
-
aria-describedby={input?.["aria-describedby"]}
|
|
132
|
-
role="combobox"
|
|
133
|
-
aria-expanded={open}
|
|
134
|
-
aria-controls={popupId}
|
|
135
|
-
aria-haspopup="dialog"
|
|
136
|
-
className="flex min-h-10 w-full items-center rounded-md border px-3 py-2 text-sm"
|
|
137
|
-
>
|
|
138
|
-
{multiple
|
|
139
|
-
? "Select items…"
|
|
140
|
-
: (selectedOptions.find(
|
|
141
|
-
(option) => option[1] === selectedIds[0],
|
|
142
|
-
)?.[0] ?? "Select…")}
|
|
143
|
-
</button>
|
|
144
|
-
</PopoverTrigger>
|
|
145
|
-
<PopoverContent id={popupId} className="w-80 space-y-2">
|
|
146
|
-
<input
|
|
147
|
-
aria-label={`Search ${label ?? "options"}`}
|
|
148
|
-
value={search}
|
|
149
|
-
placeholder="Search…"
|
|
150
|
-
className="w-full rounded border px-2 py-1"
|
|
151
|
-
onChange={(event) => {
|
|
152
|
-
setSearch(event.target.value);
|
|
153
|
-
setPage(1);
|
|
154
|
-
setLoading(true);
|
|
155
|
-
}}
|
|
156
|
-
/>
|
|
157
|
-
{loading ? (
|
|
158
|
-
<p role="status">Loading…</p>
|
|
159
|
-
) : error ? (
|
|
160
|
-
<div role="alert">
|
|
161
|
-
{error}{" "}
|
|
162
|
-
<button
|
|
163
|
-
type="button"
|
|
164
|
-
onClick={() => setRetry((count) => count + 1)}
|
|
165
|
-
>
|
|
166
|
-
Retry
|
|
167
|
-
</button>
|
|
168
|
-
</div>
|
|
169
|
-
) : (
|
|
170
|
-
<div
|
|
171
|
-
className="max-h-60 overflow-y-auto"
|
|
172
|
-
role="group"
|
|
173
|
-
aria-label="Options"
|
|
174
|
-
>
|
|
175
|
-
{result.resourceOptions.length === 0 && <p>No results found.</p>}
|
|
176
|
-
{result.resourceOptions.map((option) => (
|
|
177
|
-
<button
|
|
178
|
-
key={option[1]}
|
|
179
|
-
type="button"
|
|
180
|
-
aria-pressed={selectedIds.includes(option[1])}
|
|
181
|
-
className="block w-full rounded px-2 py-1 text-left hover:bg-muted"
|
|
182
|
-
onClick={() => select(option)}
|
|
183
|
-
>
|
|
184
|
-
{selectedIds.includes(option[1]) ? "✓ " : ""}
|
|
185
|
-
{option[0]}
|
|
186
|
-
</button>
|
|
187
|
-
))}
|
|
188
|
-
</div>
|
|
99
|
+
{input?.name && selectedIds.map((id) => <input key={id} type="hidden" name={input.name} value={id} />)}
|
|
100
|
+
<Popover open={open} onOpenChange={changeOpen}>
|
|
101
|
+
<div
|
|
102
|
+
className={cn(
|
|
103
|
+
"border-border bg-background text-foreground relative flex min-h-10 w-full items-center rounded-md border px-3 py-2 text-sm shadow-sm transition-colors",
|
|
104
|
+
"hover:border-ring/50 focus-within:border-ring focus-within:ring-ring/20 focus-within:ring-2",
|
|
105
|
+
open && "border-ring ring-ring/20 ring-2"
|
|
189
106
|
)}
|
|
190
|
-
|
|
107
|
+
>
|
|
108
|
+
<PopoverTrigger asChild>
|
|
191
109
|
<button
|
|
192
110
|
type="button"
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
111
|
+
id={input?.id}
|
|
112
|
+
data-slot="association-trigger"
|
|
113
|
+
aria-describedby={input?.["aria-describedby"]}
|
|
114
|
+
aria-label={label ?? "Select options"}
|
|
115
|
+
role="combobox"
|
|
116
|
+
aria-expanded={open}
|
|
117
|
+
aria-controls={popupId}
|
|
118
|
+
aria-haspopup="dialog"
|
|
119
|
+
className="absolute inset-0 w-full cursor-pointer rounded-md outline-none"
|
|
120
|
+
onKeyDown={(event) => {
|
|
121
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
changeOpen(true);
|
|
124
|
+
}
|
|
197
125
|
}}
|
|
198
126
|
>
|
|
199
|
-
|
|
127
|
+
<span className="sr-only">
|
|
128
|
+
{selected.length ? selected.map(([name]) => name).join(", ") : placeholder}
|
|
129
|
+
</span>
|
|
200
130
|
</button>
|
|
201
|
-
|
|
131
|
+
</PopoverTrigger>
|
|
132
|
+
<div
|
|
133
|
+
className={cn(
|
|
134
|
+
"pointer-events-none flex min-w-0 flex-1 flex-wrap items-center gap-1.5 pr-6",
|
|
135
|
+
!multiple && selected.length > 0 && "pr-12"
|
|
136
|
+
)}
|
|
137
|
+
>
|
|
138
|
+
{selected.length === 0 ? (
|
|
139
|
+
<span className="text-muted-foreground truncate">{placeholder}</span>
|
|
140
|
+
) : multiple ? (
|
|
141
|
+
selected.map(([display, id]) => (
|
|
142
|
+
<span
|
|
143
|
+
key={id}
|
|
144
|
+
className="border-border/60 bg-secondary text-secondary-foreground relative inline-flex max-w-full items-center gap-1 rounded border py-0.5 pl-2 pr-1 text-xs font-medium"
|
|
145
|
+
>
|
|
146
|
+
<span className="truncate">{display}</span>
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
aria-label={`Remove ${display}`}
|
|
150
|
+
className="text-muted-foreground hover:bg-foreground/10 hover:text-foreground focus-visible:ring-ring pointer-events-auto relative z-10 inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm outline-none focus-visible:ring-2"
|
|
151
|
+
onClick={() => remove(id)}
|
|
152
|
+
>
|
|
153
|
+
<X className="size-3" aria-hidden="true" />
|
|
154
|
+
</button>
|
|
155
|
+
</span>
|
|
156
|
+
))
|
|
157
|
+
) : (
|
|
158
|
+
<span className="truncate">{selected[0][0]}</span>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
{!multiple && selected.length > 0 && (
|
|
202
162
|
<button
|
|
203
163
|
type="button"
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
setLoading(true);
|
|
208
|
-
}}
|
|
164
|
+
aria-label={`Clear ${label ?? "selection"}`}
|
|
165
|
+
className="text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring absolute right-8 z-10 inline-flex size-6 cursor-pointer items-center justify-center rounded-sm outline-none focus-visible:ring-2"
|
|
166
|
+
onClick={() => remove(selectedIds[0])}
|
|
209
167
|
>
|
|
210
|
-
|
|
168
|
+
<X className="size-3.5" aria-hidden="true" />
|
|
211
169
|
</button>
|
|
212
|
-
|
|
170
|
+
)}
|
|
171
|
+
<ChevronsUpDown
|
|
172
|
+
className="text-muted-foreground pointer-events-none absolute right-3 size-4"
|
|
173
|
+
aria-hidden="true"
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
<PopoverContent
|
|
177
|
+
id={popupId}
|
|
178
|
+
align="start"
|
|
179
|
+
sideOffset={6}
|
|
180
|
+
className="border-border bg-popover text-popover-foreground w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg p-0 shadow-lg"
|
|
181
|
+
onOpenAutoFocus={(event) => {
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
searchRef.current?.focus();
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
<Command
|
|
187
|
+
shouldFilter={false}
|
|
188
|
+
loop
|
|
189
|
+
value={activeResultId}
|
|
190
|
+
onValueChange={setActiveResultId}
|
|
191
|
+
label={`Search ${label ?? "options"}`}
|
|
192
|
+
className="bg-popover text-popover-foreground flex w-full flex-col overflow-hidden"
|
|
193
|
+
>
|
|
194
|
+
<div className="border-border flex items-center gap-2.5 border-b px-3">
|
|
195
|
+
<span
|
|
196
|
+
data-slot="association-search-indicator"
|
|
197
|
+
className="text-muted-foreground relative size-4 shrink-0"
|
|
198
|
+
aria-hidden="true"
|
|
199
|
+
>
|
|
200
|
+
{loading ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />}
|
|
201
|
+
</span>
|
|
202
|
+
<Command.Input
|
|
203
|
+
ref={searchRef}
|
|
204
|
+
data-slot="association-search"
|
|
205
|
+
type="text"
|
|
206
|
+
aria-label={`Search ${label ?? "options"}`}
|
|
207
|
+
value={search}
|
|
208
|
+
placeholder={`Search ${label?.toLowerCase() ?? "options"}…`}
|
|
209
|
+
className="bg-transparent! text-popover-foreground placeholder:text-muted-foreground h-11 min-w-0 flex-1 border-0 px-0 text-sm shadow-none outline-none focus:border-0 focus:outline-none focus:ring-0"
|
|
210
|
+
onValueChange={(text) => {
|
|
211
|
+
setSearch(text);
|
|
212
|
+
setLoading(true);
|
|
213
|
+
}}
|
|
214
|
+
/>
|
|
215
|
+
</div>
|
|
216
|
+
<span role="status" className="sr-only">
|
|
217
|
+
{loading ? "Searching…" : ""}
|
|
218
|
+
</span>
|
|
219
|
+
<Command.List
|
|
220
|
+
className="max-h-64 min-h-20 scroll-py-1 overflow-y-auto overflow-x-hidden p-1"
|
|
221
|
+
aria-busy={loading}
|
|
222
|
+
>
|
|
223
|
+
{error ? (
|
|
224
|
+
<div role="alert" className="text-muted-foreground space-y-2 px-3 py-5 text-center text-sm">
|
|
225
|
+
<p>{error}</p>
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
className="text-foreground focus-visible:ring-ring rounded px-2 py-1 font-medium underline underline-offset-4 outline-none focus-visible:ring-2"
|
|
229
|
+
disabled={loading}
|
|
230
|
+
onClick={() => {
|
|
231
|
+
setLoading(true);
|
|
232
|
+
setRetry((count) => count + 1);
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
Try again
|
|
236
|
+
</button>
|
|
237
|
+
</div>
|
|
238
|
+
) : result ? (
|
|
239
|
+
<>
|
|
240
|
+
{result.resourceOptions.length === 0 && (
|
|
241
|
+
<p className="text-muted-foreground px-3 py-6 text-center text-sm">No results found.</p>
|
|
242
|
+
)}
|
|
243
|
+
{result.resourceOptions.map((option) => (
|
|
244
|
+
<Command.Item
|
|
245
|
+
key={option[1]}
|
|
246
|
+
value={option[1]}
|
|
247
|
+
aria-label={option[0]}
|
|
248
|
+
aria-checked={selectedIds.includes(option[1])}
|
|
249
|
+
disabled={loading}
|
|
250
|
+
onSelect={() => select(option)}
|
|
251
|
+
className="data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex min-h-9 cursor-pointer select-none items-center gap-2 rounded-md px-2.5 py-2 text-sm outline-none"
|
|
252
|
+
>
|
|
253
|
+
<span className="min-w-0 flex-1 truncate">{option[0]}</span>
|
|
254
|
+
<Check
|
|
255
|
+
className={cn(
|
|
256
|
+
"size-4 shrink-0",
|
|
257
|
+
selectedIds.includes(option[1]) ? "text-primary" : "opacity-0"
|
|
258
|
+
)}
|
|
259
|
+
aria-hidden="true"
|
|
260
|
+
/>
|
|
261
|
+
</Command.Item>
|
|
262
|
+
))}
|
|
263
|
+
</>
|
|
264
|
+
) : null}
|
|
265
|
+
</Command.List>
|
|
266
|
+
</Command>
|
|
213
267
|
</PopoverContent>
|
|
214
268
|
</Popover>
|
|
215
|
-
{!multiple && selectedIds.length > 0 && (
|
|
216
|
-
<button
|
|
217
|
-
type="button"
|
|
218
|
-
className="text-sm text-muted-foreground"
|
|
219
|
-
onClick={() => remove(selectedIds[0])}
|
|
220
|
-
>
|
|
221
|
-
Clear selection
|
|
222
|
-
</button>
|
|
223
|
-
)}
|
|
224
269
|
</div>
|
|
225
270
|
);
|
|
226
271
|
}
|
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
module Terrazzo
|
|
2
2
|
module Field
|
|
3
3
|
class Associative < Base
|
|
4
|
-
|
|
4
|
+
OPTION_LIMIT = 10
|
|
5
5
|
|
|
6
|
-
def association_options(search
|
|
7
|
-
page = [page.to_i, 1].max
|
|
6
|
+
def association_options(search:)
|
|
8
7
|
scope = candidate_scope
|
|
9
8
|
columns = options[:searchable_fields] || (%w[name title email slug code original_filename id] &
|
|
10
9
|
(associated_class.column_names + associated_class.stored_attributes.values.flatten.map(&:to_s)))
|
|
11
10
|
scope = Terrazzo::Search.new(scope, nil, search).run_for_columns(columns)
|
|
12
11
|
scope = scope.reorder(options[:order] || association_primary_key).order(association_primary_key)
|
|
13
|
-
records = scope.
|
|
12
|
+
records = scope.limit(OPTION_LIMIT).to_a
|
|
14
13
|
{
|
|
15
|
-
resourceOptions: records.
|
|
16
|
-
page: page,
|
|
17
|
-
hasMore: records.length > OPTION_PAGE_SIZE
|
|
14
|
+
resourceOptions: records.map { |record| option_for(record) }
|
|
18
15
|
}
|
|
19
16
|
end
|
|
20
17
|
|
data/lib/terrazzo/version.rb
CHANGED