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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '084ca2853c7f77397ca86d6530e21abe0d61819a464a6ce352b2c9230ae2d95f'
4
- data.tar.gz: b2b7a278bd069090a23ef1c4edbb73a7e793af55eca3941225dff5695d90e92c
3
+ metadata.gz: 7598c68c1691318c48e26a9b47176857414e6b1d0b72df96215941bd6f809ea5
4
+ data.tar.gz: e6f744d7131f5481d2b4a2c8c25db6a0547e96d9417eddc6edad0192805ad223
5
5
  SHA512:
6
- metadata.gz: e1dc0b5cb17ff57bf6261cd3eafc85583473d52e5898be93c54114972cc4d7d926a653bdbf1073ba8bec5b90c4526b314ec05d8c40e7771af608e2a986b920d8
7
- data.tar.gz: 01b2c4e3ba0b24076b7a1c0bd90e8f55a70b0b03a11b04963fef2d130939350d0b34d0d95602c04ef9020f3029ac8afdab3220859c8b5f243cf5bb512ad3b5c7
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
  [![CI](https://github.com/gohypelab/terrazzo/actions/workflows/ci.yml/badge.svg)](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
- json.options field.serializable_options(:form)
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.grouped_attributes("update") do |group|
41
+ json.array! @page.attribute_groups("update") do |group|
40
42
  json.name group[:name]
41
- json.fields do
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
- json.options field.serializable_options(:form)
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.grouped_attributes(action) do |group|
37
+ json.array! @page.attribute_groups(action) do |group|
36
38
  json.name group[:name]
37
- json.fields do
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
- import { Label } from "../../components/ui";
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, { useState, useRef, useCallback } from "react";
2
- import { X, Check } from "lucide-react";
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
- {(group.fields || []).map((field) => renderField(field))}
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
@@ -24,6 +24,10 @@ module Terrazzo
24
24
  {}
25
25
  end
26
26
 
27
+ def association_options?
28
+ false
29
+ end
30
+
27
31
  def required?
28
32
  return false unless resource
29
33
 
@@ -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
- collection = ordered_resource_options
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.id.to_s }
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] = resource_options
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
@@ -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|
@@ -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 = "\\"
@@ -1,3 +1,3 @@
1
1
  module Terrazzo
2
- VERSION = "0.7.5"
2
+ VERSION = "0.7.7"
3
3
  end
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.5
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