terrazzo 0.7.5 → 0.7.7
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 -0
- data/app/controllers/terrazzo/application_controller.rb +14 -0
- data/app/helpers/terrazzo/resource_paths_helper.rb +11 -0
- data/app/views/terrazzo/application/_edit_base.json.props +5 -14
- data/app/views/terrazzo/application/_new_base.json.props +5 -14
- data/lib/generators/terrazzo/views/templates/components/AssociationSelect.jsx +200 -0
- data/lib/generators/terrazzo/views/templates/fields/belongs_to/FormField.jsx +3 -26
- data/lib/generators/terrazzo/views/templates/fields/has_many/FormField.jsx +4 -149
- data/lib/generators/terrazzo/views/templates/pages/_form.jsx +3 -2
- data/lib/terrazzo/field/associative.rb +34 -0
- data/lib/terrazzo/field/base.rb +4 -0
- data/lib/terrazzo/field/belongs_to.rb +5 -19
- data/lib/terrazzo/field/has_many.rb +6 -16
- data/lib/terrazzo/page/form.rb +6 -0
- data/lib/terrazzo/search.rb +10 -0
- data/lib/terrazzo/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7598c68c1691318c48e26a9b47176857414e6b1d0b72df96215941bd6f809ea5
|
|
4
|
+
data.tar.gz: e6f744d7131f5481d2b4a2c8c25db6a0547e96d9417eddc6edad0192805ad223
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2b50169a5535e19fe88c591f4d6f44881f1e7923fb6559d3f50ef6091e5addf971eb789a30d2c8fb92a09eb2180ee1bec7d89bb824a6783bf2244560e17b7d40
|
|
7
|
+
data.tar.gz: 91a647284864eddc2f1d3ba22e658a02b4800415008720256b3e1d868fe62e50af20d05d8ed54cf4064e9267fdaab13480688f4de9236be8f59321bc0fa74362
|
data/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Terrazzo
|
|
2
2
|
|
|
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
|
+
|
|
3
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).
|
|
4
6
|
|
|
5
7
|
[](https://github.com/gohypelab/terrazzo/actions/workflows/ci.yml)
|
|
@@ -58,12 +58,14 @@ module Terrazzo
|
|
|
58
58
|
def new
|
|
59
59
|
@resource = resource_class.new
|
|
60
60
|
authorize_action!(@resource, :new)
|
|
61
|
+
return render_association_options("create") if params[:association].present?
|
|
61
62
|
@page = Terrazzo::Page::Form.new(dashboard, @resource)
|
|
62
63
|
end
|
|
63
64
|
|
|
64
65
|
def edit
|
|
65
66
|
@resource = find_resource(params[:id])
|
|
66
67
|
authorize_action!(@resource, :edit)
|
|
68
|
+
return render_association_options("update") if params[:association].present?
|
|
67
69
|
@page = Terrazzo::Page::Form.new(dashboard, @resource)
|
|
68
70
|
end
|
|
69
71
|
|
|
@@ -271,6 +273,18 @@ module Terrazzo
|
|
|
271
273
|
|
|
272
274
|
private
|
|
273
275
|
|
|
276
|
+
def render_association_options(action)
|
|
277
|
+
attribute = params[:association].to_s
|
|
278
|
+
attributes = dashboard.flatten_attributes(dashboard.form_attributes(action))
|
|
279
|
+
return head :not_found unless attributes.map(&:to_s).include?(attribute)
|
|
280
|
+
|
|
281
|
+
field = dashboard.attribute_type_for(attribute.to_sym).new(attribute.to_sym, nil, :form, resource: @resource)
|
|
282
|
+
return head :not_found unless field.association_options?
|
|
283
|
+
|
|
284
|
+
response.headers["Cache-Control"] = "no-store"
|
|
285
|
+
render json: field.association_options(search: params[:search])
|
|
286
|
+
end
|
|
287
|
+
|
|
274
288
|
def resolver
|
|
275
289
|
@_resolver ||= Terrazzo::ResourceResolver.new(controller_path)
|
|
276
290
|
end
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
module Terrazzo
|
|
2
2
|
module ResourcePathsHelper
|
|
3
|
+
def association_options_path(field)
|
|
4
|
+
url_for(
|
|
5
|
+
controller: controller_path,
|
|
6
|
+
action: field.resource.persisted? ? :edit : :new,
|
|
7
|
+
id: field.resource.persisted? ? terrazzo_resource_param(field.resource) : nil,
|
|
8
|
+
association: field.attribute,
|
|
9
|
+
only_path: true,
|
|
10
|
+
format: :json
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
3
14
|
def terrazzo_resource_collection_path(resource_or_class = resource_class)
|
|
4
15
|
url_for(
|
|
5
16
|
controller: terrazzo_resource_controller_path(resource_or_class),
|
|
@@ -30,26 +30,17 @@ json.form do
|
|
|
30
30
|
json.hint dashboard.attribute_hint(field.attribute, :form)
|
|
31
31
|
json.fieldType field.field_type
|
|
32
32
|
json.value field.serialize_value(:form)
|
|
33
|
-
|
|
33
|
+
options = field.serializable_options(:form)
|
|
34
|
+
options[:optionsUrl] = association_options_path(field) if field.association_options?
|
|
35
|
+
json.options options
|
|
34
36
|
json.required field.required?
|
|
35
37
|
json.input field.form_input_attributes(param_key)
|
|
36
38
|
end
|
|
37
39
|
end
|
|
38
40
|
json.fieldGroups do
|
|
39
|
-
json.array! @page.
|
|
41
|
+
json.array! @page.attribute_groups("update") do |group|
|
|
40
42
|
json.name group[:name]
|
|
41
|
-
json.
|
|
42
|
-
json.array! group[:fields] do |field|
|
|
43
|
-
json.attribute field.attribute.to_s
|
|
44
|
-
json.label dashboard.attribute_label(field.attribute, :form)
|
|
45
|
-
json.hint dashboard.attribute_hint(field.attribute, :form)
|
|
46
|
-
json.fieldType field.field_type
|
|
47
|
-
json.value field.serialize_value(:form)
|
|
48
|
-
json.options field.serializable_options(:form)
|
|
49
|
-
json.required field.required?
|
|
50
|
-
json.input field.form_input_attributes(param_key)
|
|
51
|
-
end
|
|
52
|
-
end
|
|
43
|
+
json.attributes group[:attributes]
|
|
53
44
|
end
|
|
54
45
|
end
|
|
55
46
|
end
|
|
@@ -26,26 +26,17 @@ json.form do
|
|
|
26
26
|
json.hint dashboard.attribute_hint(field.attribute, :form)
|
|
27
27
|
json.fieldType field.field_type
|
|
28
28
|
json.value field.serialize_value(:form)
|
|
29
|
-
|
|
29
|
+
options = field.serializable_options(:form)
|
|
30
|
+
options[:optionsUrl] = association_options_path(field) if field.association_options?
|
|
31
|
+
json.options options
|
|
30
32
|
json.required field.required?
|
|
31
33
|
json.input field.form_input_attributes(param_key)
|
|
32
34
|
end
|
|
33
35
|
end
|
|
34
36
|
json.fieldGroups do
|
|
35
|
-
json.array! @page.
|
|
37
|
+
json.array! @page.attribute_groups(action) do |group|
|
|
36
38
|
json.name group[:name]
|
|
37
|
-
json.
|
|
38
|
-
json.array! group[:fields] do |field|
|
|
39
|
-
json.attribute field.attribute.to_s
|
|
40
|
-
json.label dashboard.attribute_label(field.attribute, :form)
|
|
41
|
-
json.hint dashboard.attribute_hint(field.attribute, :form)
|
|
42
|
-
json.fieldType field.field_type
|
|
43
|
-
json.value field.serialize_value(:form)
|
|
44
|
-
json.options field.serializable_options(:form)
|
|
45
|
-
json.required field.required?
|
|
46
|
-
json.input field.form_input_attributes(param_key)
|
|
47
|
-
end
|
|
48
|
-
end
|
|
39
|
+
json.attributes group[:attributes]
|
|
49
40
|
end
|
|
50
41
|
end
|
|
51
42
|
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import React, { useEffect, useId, useState } from "react";
|
|
2
|
+
import { Label, Popover, PopoverContent, PopoverTrigger } from "./ui";
|
|
3
|
+
|
|
4
|
+
export function AssociationSelect({
|
|
5
|
+
value,
|
|
6
|
+
options,
|
|
7
|
+
input,
|
|
8
|
+
label,
|
|
9
|
+
required,
|
|
10
|
+
multiple = false,
|
|
11
|
+
onChange,
|
|
12
|
+
}) {
|
|
13
|
+
const popupId = useId();
|
|
14
|
+
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,
|
|
24
|
+
);
|
|
25
|
+
const [open, setOpen] = useState(false);
|
|
26
|
+
const [search, setSearch] = useState("");
|
|
27
|
+
const [result, setResult] = useState({ resourceOptions: [] });
|
|
28
|
+
const [loading, setLoading] = useState(false);
|
|
29
|
+
const [error, setError] = useState("");
|
|
30
|
+
const [retry, setRetry] = useState(0);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!open) return;
|
|
34
|
+
const controller = new window.AbortController();
|
|
35
|
+
let active = true;
|
|
36
|
+
setLoading(true);
|
|
37
|
+
setError("");
|
|
38
|
+
const timer = setTimeout(async () => {
|
|
39
|
+
try {
|
|
40
|
+
const url = new URL(options.optionsUrl, window.location.origin);
|
|
41
|
+
url.searchParams.set("search", search);
|
|
42
|
+
const response = await window.fetch(url, {
|
|
43
|
+
headers: { Accept: "application/json" },
|
|
44
|
+
signal: controller.signal,
|
|
45
|
+
credentials: "same-origin",
|
|
46
|
+
});
|
|
47
|
+
if (!response.ok) throw new Error("Could not load options.");
|
|
48
|
+
const data = await response.json();
|
|
49
|
+
if (active) setResult(data);
|
|
50
|
+
} catch (failure) {
|
|
51
|
+
if (active && failure.name !== "AbortError")
|
|
52
|
+
setError("Could not load options.");
|
|
53
|
+
} finally {
|
|
54
|
+
if (active) setLoading(false);
|
|
55
|
+
}
|
|
56
|
+
}, 200);
|
|
57
|
+
return () => {
|
|
58
|
+
active = false;
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
controller.abort();
|
|
61
|
+
};
|
|
62
|
+
}, [open, search, options.optionsUrl, retry]);
|
|
63
|
+
|
|
64
|
+
const select = (option) => {
|
|
65
|
+
const id = option[1];
|
|
66
|
+
const nextIds = multiple
|
|
67
|
+
? selectedIds.includes(id)
|
|
68
|
+
? selectedIds.filter((item) => item !== id)
|
|
69
|
+
: [...selectedIds, id]
|
|
70
|
+
: [id];
|
|
71
|
+
const nextOptions = [
|
|
72
|
+
...selectedOptions.filter((item) => item[1] !== id),
|
|
73
|
+
option,
|
|
74
|
+
].filter((item) => nextIds.includes(item[1]));
|
|
75
|
+
setSelectedIds(nextIds);
|
|
76
|
+
setSelectedOptions(nextOptions);
|
|
77
|
+
onChange?.(multiple ? nextIds : id, option);
|
|
78
|
+
if (!multiple) setOpen(false);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const remove = (id) => {
|
|
82
|
+
const nextIds = selectedIds.filter((item) => item !== id);
|
|
83
|
+
setSelectedIds(nextIds);
|
|
84
|
+
setSelectedOptions((items) => items.filter((item) => item[1] !== id));
|
|
85
|
+
onChange?.(multiple ? nextIds : "", null);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="space-y-2">
|
|
90
|
+
{label && (
|
|
91
|
+
<Label htmlFor={input?.id}>
|
|
92
|
+
{label}
|
|
93
|
+
{required && " *"}
|
|
94
|
+
</Label>
|
|
95
|
+
)}
|
|
96
|
+
{input?.name && <input type="hidden" name={input.name} value="" />}
|
|
97
|
+
{input?.name &&
|
|
98
|
+
selectedIds.map((id) => (
|
|
99
|
+
<input key={id} type="hidden" name={input.name} value={id} />
|
|
100
|
+
))}
|
|
101
|
+
{multiple && (
|
|
102
|
+
<div className="flex flex-wrap gap-1">
|
|
103
|
+
{selectedOptions
|
|
104
|
+
.filter((option) => selectedIds.includes(option[1]))
|
|
105
|
+
.map(([display, id]) => (
|
|
106
|
+
<button
|
|
107
|
+
key={id}
|
|
108
|
+
type="button"
|
|
109
|
+
className="rounded bg-muted px-2 py-1 text-sm"
|
|
110
|
+
aria-label={`Remove ${display}`}
|
|
111
|
+
onClick={() => remove(id)}
|
|
112
|
+
>
|
|
113
|
+
{display} ×
|
|
114
|
+
</button>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
<Popover
|
|
119
|
+
open={open}
|
|
120
|
+
onOpenChange={(next) => {
|
|
121
|
+
setOpen(next);
|
|
122
|
+
if (next) setLoading(true);
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
<PopoverTrigger asChild>
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
id={input?.id}
|
|
129
|
+
aria-describedby={input?.["aria-describedby"]}
|
|
130
|
+
role="combobox"
|
|
131
|
+
aria-expanded={open}
|
|
132
|
+
aria-controls={popupId}
|
|
133
|
+
aria-haspopup="dialog"
|
|
134
|
+
className="flex min-h-10 w-full items-center rounded-md border px-3 py-2 text-sm"
|
|
135
|
+
>
|
|
136
|
+
{multiple
|
|
137
|
+
? "Select items…"
|
|
138
|
+
: (selectedOptions.find(
|
|
139
|
+
(option) => option[1] === selectedIds[0],
|
|
140
|
+
)?.[0] ?? "Select…")}
|
|
141
|
+
</button>
|
|
142
|
+
</PopoverTrigger>
|
|
143
|
+
<PopoverContent id={popupId} className="w-80 space-y-2">
|
|
144
|
+
<input
|
|
145
|
+
aria-label={`Search ${label ?? "options"}`}
|
|
146
|
+
value={search}
|
|
147
|
+
placeholder="Search…"
|
|
148
|
+
className="w-full rounded border px-2 py-1"
|
|
149
|
+
onChange={(event) => {
|
|
150
|
+
setSearch(event.target.value);
|
|
151
|
+
setLoading(true);
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
{loading ? (
|
|
155
|
+
<p role="status">Loading…</p>
|
|
156
|
+
) : error ? (
|
|
157
|
+
<div role="alert">
|
|
158
|
+
{error}{" "}
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
onClick={() => setRetry((count) => count + 1)}
|
|
162
|
+
>
|
|
163
|
+
Retry
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
) : (
|
|
167
|
+
<div
|
|
168
|
+
className="max-h-60 overflow-y-auto"
|
|
169
|
+
role="group"
|
|
170
|
+
aria-label="Options"
|
|
171
|
+
>
|
|
172
|
+
{result.resourceOptions.length === 0 && <p>No results found.</p>}
|
|
173
|
+
{result.resourceOptions.map((option) => (
|
|
174
|
+
<button
|
|
175
|
+
key={option[1]}
|
|
176
|
+
type="button"
|
|
177
|
+
aria-pressed={selectedIds.includes(option[1])}
|
|
178
|
+
className="block w-full rounded px-2 py-1 text-left hover:bg-muted"
|
|
179
|
+
onClick={() => select(option)}
|
|
180
|
+
>
|
|
181
|
+
{selectedIds.includes(option[1]) ? "✓ " : ""}
|
|
182
|
+
{option[0]}
|
|
183
|
+
</button>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</PopoverContent>
|
|
188
|
+
</Popover>
|
|
189
|
+
{!multiple && selectedIds.length > 0 && (
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
className="text-sm text-muted-foreground"
|
|
193
|
+
onClick={() => remove(selectedIds[0])}
|
|
194
|
+
>
|
|
195
|
+
Clear selection
|
|
196
|
+
</button>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -1,29 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { AssociationSelect } from "../../components";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export function FormField({ value, label, input, options, required }) {
|
|
6
|
-
const resourceOptions = options?.resourceOptions ?? [];
|
|
7
|
-
|
|
8
|
-
return (
|
|
9
|
-
<div className="space-y-2">
|
|
10
|
-
{label &&
|
|
11
|
-
<Label htmlFor={input?.id}>
|
|
12
|
-
{label}{required && <span className="text-destructive"> *</span>}
|
|
13
|
-
</Label>
|
|
14
|
-
}
|
|
15
|
-
<select
|
|
16
|
-
defaultValue={value != null ? String(value) : ""}
|
|
17
|
-
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
18
|
-
{...input}>
|
|
19
|
-
|
|
20
|
-
<option value="">Select...</option>
|
|
21
|
-
{resourceOptions.map(([display, id]) =>
|
|
22
|
-
<option key={id} value={id}>
|
|
23
|
-
{display}
|
|
24
|
-
</option>
|
|
25
|
-
)}
|
|
26
|
-
</select>
|
|
27
|
-
</div>);
|
|
28
|
-
|
|
4
|
+
export function FormField(props) {
|
|
5
|
+
return <AssociationSelect {...props} />;
|
|
29
6
|
}
|
|
@@ -1,151 +1,6 @@
|
|
|
1
|
-
import React
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
import { Label } from "../../components/ui";
|
|
5
|
-
import { Badge } from "../../components/ui";
|
|
6
|
-
import { Popover, PopoverTrigger, PopoverContent } from "../../components/ui";
|
|
7
|
-
import { cn } from "terrazzo";
|
|
8
|
-
|
|
9
|
-
export function FormField({ value, label, input, options, required }) {
|
|
10
|
-
const resourceOptions = options?.resourceOptions ?? [];
|
|
11
|
-
const initialIds = value ?? [];
|
|
12
|
-
|
|
13
|
-
const [selectedIds, setSelectedIds] = useState(() => new Set(initialIds));
|
|
14
|
-
const [search, setSearch] = useState("");
|
|
15
|
-
const [open, setOpen] = useState(false);
|
|
16
|
-
const inputRef = useRef(null);
|
|
17
|
-
|
|
18
|
-
const inputProps = input;
|
|
19
|
-
const inputName = inputProps?.name;
|
|
20
|
-
const inputId = inputProps?.id;
|
|
21
|
-
|
|
22
|
-
const toggleOption = useCallback((id) => {
|
|
23
|
-
setSelectedIds((prev) => {
|
|
24
|
-
const next = new Set(prev);
|
|
25
|
-
if (next.has(id)) {
|
|
26
|
-
next.delete(id);
|
|
27
|
-
} else {
|
|
28
|
-
next.add(id);
|
|
29
|
-
}
|
|
30
|
-
return next;
|
|
31
|
-
});
|
|
32
|
-
setSearch("");
|
|
33
|
-
}, []);
|
|
34
|
-
|
|
35
|
-
const removeOption = useCallback((id, e) => {
|
|
36
|
-
e.preventDefault();
|
|
37
|
-
e.stopPropagation();
|
|
38
|
-
setSelectedIds((prev) => {
|
|
39
|
-
const next = new Set(prev);
|
|
40
|
-
next.delete(id);
|
|
41
|
-
return next;
|
|
42
|
-
});
|
|
43
|
-
}, []);
|
|
44
|
-
|
|
45
|
-
const selectedOptions = resourceOptions.filter(([, id]) => selectedIds.has(id));
|
|
46
|
-
const filteredOptions = resourceOptions.filter(([display]) =>
|
|
47
|
-
display.toLowerCase().includes(search.toLowerCase())
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
return (
|
|
51
|
-
<div className="space-y-2">
|
|
52
|
-
{label &&
|
|
53
|
-
<Label htmlFor={inputId}>
|
|
54
|
-
{label}{required && <span className="text-destructive"> *</span>}
|
|
55
|
-
</Label>
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
{/* Hidden inputs for form submission */}
|
|
59
|
-
{inputName && <input type="hidden" name={inputName} value="" />}
|
|
60
|
-
{selectedIds.size > 0 && inputName &&
|
|
61
|
-
Array.from(selectedIds).map((id) =>
|
|
62
|
-
<input key={id} type="hidden" name={inputName} value={id} />
|
|
63
|
-
)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
<Popover open={open} onOpenChange={setOpen}>
|
|
67
|
-
<PopoverTrigger asChild>
|
|
68
|
-
<button
|
|
69
|
-
type="button"
|
|
70
|
-
role="combobox"
|
|
71
|
-
aria-expanded={open}
|
|
72
|
-
id={inputId}
|
|
73
|
-
className={cn(
|
|
74
|
-
"flex min-h-10 w-full flex-wrap items-center gap-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background",
|
|
75
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
76
|
-
"hover:bg-accent/50 cursor-pointer"
|
|
77
|
-
)}>
|
|
78
|
-
|
|
79
|
-
{selectedOptions.length > 0 ?
|
|
80
|
-
selectedOptions.map(([display, id]) =>
|
|
81
|
-
<Badge key={id} variant="secondary" className="gap-1">
|
|
82
|
-
{display}
|
|
83
|
-
<span
|
|
84
|
-
role="button"
|
|
85
|
-
tabIndex={0}
|
|
86
|
-
className="ml-0.5 rounded-full outline-none hover:bg-muted-foreground/20 focus:bg-muted-foreground/20"
|
|
87
|
-
onPointerDown={(e) => removeOption(id, e)}
|
|
88
|
-
onKeyDown={(e) => {
|
|
89
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
90
|
-
e.preventDefault();
|
|
91
|
-
removeOption(id, e);
|
|
92
|
-
}
|
|
93
|
-
}}>
|
|
94
|
-
|
|
95
|
-
<X className="h-3 w-3" />
|
|
96
|
-
</span>
|
|
97
|
-
</Badge>
|
|
98
|
-
) :
|
|
99
|
-
|
|
100
|
-
<span className="text-muted-foreground">Select {label?.toLowerCase() ?? "items"}...</span>
|
|
101
|
-
}
|
|
102
|
-
</button>
|
|
103
|
-
</PopoverTrigger>
|
|
104
|
-
<PopoverContent
|
|
105
|
-
className="p-0"
|
|
106
|
-
onOpenAutoFocus={(e) => {
|
|
107
|
-
e.preventDefault();
|
|
108
|
-
inputRef.current?.focus();
|
|
109
|
-
}}>
|
|
110
|
-
|
|
111
|
-
<div className="p-2 border-b">
|
|
112
|
-
<input
|
|
113
|
-
ref={inputRef}
|
|
114
|
-
type="text"
|
|
115
|
-
className="flex h-8 w-full rounded-md bg-transparent px-2 py-1 text-sm outline-none placeholder:text-muted-foreground"
|
|
116
|
-
placeholder={`Search ${label?.toLowerCase() ?? "items"}...`}
|
|
117
|
-
value={search}
|
|
118
|
-
onChange={(e) => setSearch(e.target.value)} />
|
|
119
|
-
|
|
120
|
-
</div>
|
|
121
|
-
<div className="max-h-60 overflow-y-auto p-1">
|
|
122
|
-
{filteredOptions.length === 0 ?
|
|
123
|
-
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
|
|
124
|
-
No results found.
|
|
125
|
-
</div> :
|
|
126
|
-
|
|
127
|
-
filteredOptions.map(([display, id]) => {
|
|
128
|
-
const isSelected = selectedIds.has(id);
|
|
129
|
-
return (
|
|
130
|
-
<button
|
|
131
|
-
key={id}
|
|
132
|
-
type="button"
|
|
133
|
-
className={cn(
|
|
134
|
-
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer",
|
|
135
|
-
"outline-none hover:bg-accent hover:text-accent-foreground",
|
|
136
|
-
"focus:bg-accent focus:text-accent-foreground"
|
|
137
|
-
)}
|
|
138
|
-
onClick={() => toggleOption(id)}>
|
|
139
|
-
|
|
140
|
-
<Check className={cn("h-4 w-4 shrink-0", isSelected ? "opacity-100" : "opacity-0")} />
|
|
141
|
-
{display}
|
|
142
|
-
</button>);
|
|
143
|
-
|
|
144
|
-
})
|
|
145
|
-
}
|
|
146
|
-
</div>
|
|
147
|
-
</PopoverContent>
|
|
148
|
-
</Popover>
|
|
149
|
-
</div>);
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AssociationSelect } from "../../components";
|
|
150
3
|
|
|
4
|
+
export function FormField(props) {
|
|
5
|
+
return <AssociationSelect {...props} multiple />;
|
|
151
6
|
}
|
|
@@ -7,6 +7,7 @@ export function AdminForm({ form = {}, errors = [] }) {
|
|
|
7
7
|
const { props: formProps = {}, extras = {}, fieldGroups = [], fields = [] } = form;
|
|
8
8
|
const groups = Array.isArray(fieldGroups) ? fieldGroups : [];
|
|
9
9
|
const hasFieldGroups = groups.length > 0;
|
|
10
|
+
const fieldsByAttribute = new Map(fields.map((field) => [field.attribute, field]));
|
|
10
11
|
|
|
11
12
|
const renderField = (field) => {
|
|
12
13
|
const hintId = field.hint && field.input?.id ? `${field.input.id}_hint` : null;
|
|
@@ -15,7 +16,7 @@ export function AdminForm({ form = {}, errors = [] }) {
|
|
|
15
16
|
field.input;
|
|
16
17
|
|
|
17
18
|
return (
|
|
18
|
-
<div key={field.attribute} className="space-y-2">
|
|
19
|
+
<div key={`${field.attribute}:${JSON.stringify(field.value)}`} className="space-y-2">
|
|
19
20
|
<FieldRenderer
|
|
20
21
|
mode="form"
|
|
21
22
|
fieldType={field.fieldType}
|
|
@@ -63,7 +64,7 @@ export function AdminForm({ form = {}, errors = [] }) {
|
|
|
63
64
|
<legend className="text-lg font-semibold">{group.name}</legend>
|
|
64
65
|
)}
|
|
65
66
|
<div className="space-y-6">
|
|
66
|
-
{
|
|
67
|
+
{group.attributes.map((attribute) => renderField(fieldsByAttribute.get(attribute)))}
|
|
67
68
|
</div>
|
|
68
69
|
</fieldset>
|
|
69
70
|
)}
|
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
module Terrazzo
|
|
2
2
|
module Field
|
|
3
3
|
class Associative < Base
|
|
4
|
+
OPTION_LIMIT = 10
|
|
5
|
+
|
|
6
|
+
def association_options(search:)
|
|
7
|
+
scope = candidate_scope
|
|
8
|
+
columns = options[:searchable_fields] || (%w[name title email slug code original_filename id] &
|
|
9
|
+
(associated_class.column_names + associated_class.stored_attributes.values.flatten.map(&:to_s)))
|
|
10
|
+
scope = Terrazzo::Search.new(scope, nil, search).run_for_columns(columns)
|
|
11
|
+
scope = scope.reorder(options[:order] || association_primary_key).order(association_primary_key)
|
|
12
|
+
records = scope.limit(OPTION_LIMIT).to_a
|
|
13
|
+
{
|
|
14
|
+
resourceOptions: records.map { |record| option_for(record) }
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def selected_resource_options
|
|
19
|
+
records = data.nil? ? [] : (data.respond_to?(:to_ary) ? data.to_ary : [data])
|
|
20
|
+
records.map { |record| option_for(record) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def candidate_scope
|
|
24
|
+
scope = if options[:scope].is_a?(Proc)
|
|
25
|
+
options[:scope].call(associated_class)
|
|
26
|
+
elsif options[:scope]
|
|
27
|
+
associated_class.public_send(options[:scope])
|
|
28
|
+
else
|
|
29
|
+
associated_class.all
|
|
30
|
+
end
|
|
31
|
+
options.key?(:includes) ? scope.includes(*options[:includes]) : scope
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def option_for(record)
|
|
35
|
+
[display_name(record), record.public_send(association_primary_key).to_s]
|
|
36
|
+
end
|
|
37
|
+
|
|
4
38
|
class << self
|
|
5
39
|
def associative?
|
|
6
40
|
true
|
data/lib/terrazzo/field/base.rb
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
module Terrazzo
|
|
2
2
|
module Field
|
|
3
3
|
class BelongsTo < Associative
|
|
4
|
+
def association_options?
|
|
5
|
+
true
|
|
6
|
+
end
|
|
7
|
+
|
|
4
8
|
def serialize_value(mode)
|
|
5
9
|
case mode
|
|
6
10
|
when :form
|
|
@@ -13,11 +17,7 @@ module Terrazzo
|
|
|
13
17
|
|
|
14
18
|
def serializable_options(page = nil)
|
|
15
19
|
return {} unless page == :form && resource
|
|
16
|
-
|
|
17
|
-
if options[:include_blank]
|
|
18
|
-
collection = [["", nil]] + collection
|
|
19
|
-
end
|
|
20
|
-
{ resourceOptions: collection }
|
|
20
|
+
{ resourceOptions: selected_resource_options }
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
class << self
|
|
@@ -35,20 +35,6 @@ module Terrazzo
|
|
|
35
35
|
|
|
36
36
|
private
|
|
37
37
|
|
|
38
|
-
def ordered_resource_options
|
|
39
|
-
return [] unless associated_class
|
|
40
|
-
scope = if options[:scope].is_a?(Proc)
|
|
41
|
-
options[:scope].call(associated_class)
|
|
42
|
-
elsif options[:scope]
|
|
43
|
-
associated_class.public_send(options[:scope])
|
|
44
|
-
else
|
|
45
|
-
associated_class.all
|
|
46
|
-
end
|
|
47
|
-
scope = scope.reorder(options[:order]) if options[:order]
|
|
48
|
-
pk = association_primary_key
|
|
49
|
-
scope.map { |r| [display_name(r), r.public_send(pk).to_s] }
|
|
50
|
-
end
|
|
51
|
-
|
|
52
38
|
def foreign_key_value
|
|
53
39
|
return nil unless resource
|
|
54
40
|
|
|
@@ -3,6 +3,10 @@ module Terrazzo
|
|
|
3
3
|
class HasMany < Associative
|
|
4
4
|
DEFAULT_PER_PAGE = 5
|
|
5
5
|
|
|
6
|
+
def association_options?
|
|
7
|
+
true
|
|
8
|
+
end
|
|
9
|
+
|
|
6
10
|
def serialize_value(mode)
|
|
7
11
|
return nil if data.nil?
|
|
8
12
|
|
|
@@ -13,7 +17,7 @@ module Terrazzo
|
|
|
13
17
|
label = label.singularize if count == 1
|
|
14
18
|
{ count: count, label: label }
|
|
15
19
|
when :form
|
|
16
|
-
data.map { |r| r.
|
|
20
|
+
data.map { |r| r.public_send(association_primary_key).to_s }
|
|
17
21
|
when :show
|
|
18
22
|
serialize_show_value
|
|
19
23
|
else
|
|
@@ -24,7 +28,7 @@ module Terrazzo
|
|
|
24
28
|
def serializable_options(page = nil)
|
|
25
29
|
opts = {}
|
|
26
30
|
if page == :form && resource
|
|
27
|
-
opts[:resourceOptions] =
|
|
31
|
+
opts[:resourceOptions] = selected_resource_options
|
|
28
32
|
end
|
|
29
33
|
if options.key?(:render_actions)
|
|
30
34
|
opts[:renderActions] = options[:render_actions]
|
|
@@ -187,20 +191,6 @@ module Terrazzo
|
|
|
187
191
|
}.compact
|
|
188
192
|
end
|
|
189
193
|
|
|
190
|
-
def resource_options
|
|
191
|
-
return [] unless associated_class
|
|
192
|
-
scope = if options[:scope].is_a?(Proc)
|
|
193
|
-
options[:scope].call(associated_class)
|
|
194
|
-
elsif options[:scope]
|
|
195
|
-
associated_class.public_send(options[:scope])
|
|
196
|
-
else
|
|
197
|
-
associated_class.all
|
|
198
|
-
end
|
|
199
|
-
scope = scope.includes(*options[:includes]) if options.key?(:includes)
|
|
200
|
-
pk = association_primary_key
|
|
201
|
-
scope.map { |r| [display_name(r), r.public_send(pk).to_s] }
|
|
202
|
-
end
|
|
203
|
-
|
|
204
194
|
def apply_sorting(records)
|
|
205
195
|
sort_by = options[:sort_by]
|
|
206
196
|
return records unless sort_by
|
data/lib/terrazzo/page/form.rb
CHANGED
|
@@ -13,6 +13,12 @@ module Terrazzo
|
|
|
13
13
|
normalize_groups(attrs, :form)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def attribute_groups(action = nil)
|
|
17
|
+
attrs = dashboard.form_attributes(action)
|
|
18
|
+
groups = attrs.is_a?(Hash) ? attrs : { "" => attrs }
|
|
19
|
+
groups.map { |name, fields| { name: name, attributes: fields.map(&:to_s) } }
|
|
20
|
+
end
|
|
21
|
+
|
|
16
22
|
def attributes(action = nil)
|
|
17
23
|
attrs = dashboard.form_attributes(action)
|
|
18
24
|
dashboard.flatten_attributes(attrs).map do |attr|
|
data/lib/terrazzo/search.rb
CHANGED
|
@@ -12,6 +12,16 @@ module Terrazzo
|
|
|
12
12
|
term.blank? ? scoped_resource : search_results
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
def run_for_columns(columns)
|
|
16
|
+
return scoped_resource if term.blank?
|
|
17
|
+
return scoped_resource.none if columns.empty?
|
|
18
|
+
|
|
19
|
+
conditions = columns.map do |column|
|
|
20
|
+
text_attribute(scoped_resource.model, column).matches(search_pattern, LIKE_ESCAPE)
|
|
21
|
+
end
|
|
22
|
+
scoped_resource.where(conditions.reduce(:or))
|
|
23
|
+
end
|
|
24
|
+
|
|
15
25
|
private
|
|
16
26
|
|
|
17
27
|
LIKE_ESCAPE = "\\"
|
data/lib/terrazzo/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: terrazzo
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Terrazzo Contributors
|
|
@@ -193,6 +193,7 @@ files:
|
|
|
193
193
|
- lib/generators/terrazzo/views/new_generator.rb
|
|
194
194
|
- lib/generators/terrazzo/views/page_mapping_helper.rb
|
|
195
195
|
- lib/generators/terrazzo/views/show_generator.rb
|
|
196
|
+
- lib/generators/terrazzo/views/templates/components/AssociationSelect.jsx
|
|
196
197
|
- lib/generators/terrazzo/views/templates/components/CollectionFilters.jsx
|
|
197
198
|
- lib/generators/terrazzo/views/templates/components/CollectionItemActions.jsx
|
|
198
199
|
- lib/generators/terrazzo/views/templates/components/CollectionToolbarActions.jsx
|