terrazzo 0.6.0 → 0.7.0

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.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +101 -0
  3. data/Rakefile +1 -0
  4. data/app/controllers/terrazzo/application_controller.rb +63 -14
  5. data/app/helpers/terrazzo/collection_actions_helper.rb +38 -13
  6. data/app/helpers/terrazzo/resource_paths_helper.rb +55 -0
  7. data/app/views/terrazzo/application/_edit_base.json.props +7 -4
  8. data/app/views/terrazzo/application/_index_base.json.props +83 -7
  9. data/app/views/terrazzo/application/_navigation.json.props +7 -4
  10. data/app/views/terrazzo/application/_new_base.json.props +6 -3
  11. data/app/views/terrazzo/application/_show_base.json.props +7 -5
  12. data/lib/generators/terrazzo/dashboard/dashboard_generator.rb +48 -24
  13. data/lib/generators/terrazzo/dashboard/templates/controller.rb.erb +8 -4
  14. data/lib/generators/terrazzo/dashboard/templates/dashboard.rb.erb +33 -25
  15. data/lib/generators/terrazzo/eject/eject_generator.rb +223 -113
  16. data/lib/generators/terrazzo/field/field_generator.rb +51 -7
  17. data/lib/generators/terrazzo/field/templates/FormField.jsx.erb +2 -3
  18. data/lib/generators/terrazzo/install/install_generator.rb +641 -4
  19. data/lib/generators/terrazzo/install/templates/admin.css +2 -0
  20. data/lib/generators/terrazzo/install/templates/application.js.erb +3 -0
  21. data/lib/generators/terrazzo/install/templates/components.json.erb +17 -0
  22. data/lib/generators/terrazzo/install/templates/custom_page_mapping.js.erb +5 -0
  23. data/lib/generators/terrazzo/install/templates/generated_page_mapping.js.erb +10 -0
  24. data/lib/generators/terrazzo/install/templates/jsconfig.json +8 -0
  25. data/lib/generators/terrazzo/install/templates/page_to_page_mapping.js.erb +9 -1
  26. data/lib/generators/terrazzo/install/templates/superglue.html.erb.erb +2 -2
  27. data/lib/generators/terrazzo/routes/routes_generator.rb +55 -20
  28. data/lib/generators/terrazzo/views/edit_generator.rb +16 -10
  29. data/lib/generators/terrazzo/views/eject_compatibility.rb +19 -0
  30. data/lib/generators/terrazzo/views/field_generator.rb +18 -6
  31. data/lib/generators/terrazzo/views/generated_defaults_helper.rb +110 -0
  32. data/lib/generators/terrazzo/views/index_generator.rb +9 -8
  33. data/lib/generators/terrazzo/views/layout_generator.rb +4 -3
  34. data/lib/generators/terrazzo/views/navigation_generator.rb +5 -1
  35. data/lib/generators/terrazzo/views/new_generator.rb +16 -10
  36. data/lib/generators/terrazzo/views/page_mapping_helper.rb +131 -8
  37. data/lib/generators/terrazzo/views/show_generator.rb +7 -6
  38. data/lib/generators/terrazzo/views/templates/components/CollectionFilters.jsx +25 -0
  39. data/lib/generators/terrazzo/views/templates/components/CollectionItemActions.jsx +68 -0
  40. data/lib/generators/terrazzo/views/templates/components/CollectionToolbarActions.jsx +60 -0
  41. data/lib/generators/terrazzo/views/templates/components/HasManyPagination.jsx +36 -0
  42. data/lib/generators/terrazzo/views/templates/components/Layout.jsx +9 -4
  43. data/lib/generators/terrazzo/views/templates/components/Pagination.jsx +32 -24
  44. data/lib/generators/terrazzo/views/templates/components/ResourceTable.jsx +172 -0
  45. data/lib/generators/terrazzo/views/templates/components/SearchBar.jsx +14 -6
  46. data/lib/generators/terrazzo/views/templates/components/SortableHeader.jsx +7 -5
  47. data/lib/generators/terrazzo/views/templates/components/app-sidebar.jsx +1 -1
  48. data/lib/generators/terrazzo/views/templates/components/site-header.jsx +2 -2
  49. data/lib/generators/terrazzo/views/templates/fields/asset/FormField.jsx +15 -0
  50. data/lib/generators/terrazzo/views/templates/fields/asset/IndexField.jsx +5 -0
  51. data/lib/generators/terrazzo/views/templates/fields/asset/ShowField.jsx +13 -0
  52. data/lib/generators/terrazzo/views/templates/fields/belongs_to/FormField.jsx +1 -1
  53. data/lib/generators/terrazzo/views/templates/fields/boolean/FormField.jsx +1 -1
  54. data/lib/generators/terrazzo/views/templates/fields/boolean/IndexField.jsx +1 -1
  55. data/lib/generators/terrazzo/views/templates/fields/has_many/FormField.jsx +3 -3
  56. data/lib/generators/terrazzo/views/templates/fields/has_many/IndexField.jsx +4 -3
  57. data/lib/generators/terrazzo/views/templates/fields/has_many/ShowField.jsx +21 -35
  58. data/lib/generators/terrazzo/views/templates/fields/has_one/FormField.jsx +1 -1
  59. data/lib/generators/terrazzo/views/templates/fields/hstore/FormField.jsx +5 -5
  60. data/lib/generators/terrazzo/views/templates/fields/hstore/IndexField.jsx +0 -2
  61. data/lib/generators/terrazzo/views/templates/fields/hstore/ShowField.jsx +1 -1
  62. data/lib/generators/terrazzo/views/templates/fields/number/IndexField.jsx +1 -1
  63. data/lib/generators/terrazzo/views/templates/fields/number/ShowField.jsx +1 -1
  64. data/lib/generators/terrazzo/views/templates/fields/polymorphic/FormField.jsx +3 -2
  65. data/lib/generators/terrazzo/views/templates/fields/rich_text/FormField.jsx +2 -2
  66. data/lib/generators/terrazzo/views/templates/fields/select/FormField.jsx +1 -1
  67. data/lib/generators/terrazzo/views/templates/fields/select/IndexField.jsx +1 -1
  68. data/lib/generators/terrazzo/views/templates/fields/shared/TextInputFormField.jsx +2 -2
  69. data/lib/generators/terrazzo/views/templates/fields/text/FormField.jsx +2 -2
  70. data/lib/generators/terrazzo/views/templates/pages/_collection.jsx +5 -3
  71. data/lib/generators/terrazzo/views/templates/pages/_form.jsx +37 -30
  72. data/lib/generators/terrazzo/views/templates/pages/_navigation.json.props +14 -4
  73. data/lib/generators/terrazzo/views/templates/pages/edit.jsx +6 -4
  74. data/lib/generators/terrazzo/views/templates/pages/index.jsx +20 -8
  75. data/lib/generators/terrazzo/views/templates/pages/new.jsx +8 -4
  76. data/lib/generators/terrazzo/views/templates/pages/show.jsx +30 -32
  77. data/lib/generators/terrazzo/views/views_generator.rb +9 -5
  78. data/lib/terrazzo/base_dashboard.rb +167 -5
  79. data/lib/terrazzo/csv_export.rb +38 -0
  80. data/lib/terrazzo/field/associative.rb +16 -4
  81. data/lib/terrazzo/field/base.rb +4 -0
  82. data/lib/terrazzo/field/belongs_to.rb +1 -2
  83. data/lib/terrazzo/field/deferred.rb +4 -0
  84. data/lib/terrazzo/field/has_many.rb +79 -11
  85. data/lib/terrazzo/field/password.rb +6 -0
  86. data/lib/terrazzo/field/rich_text.rb +13 -0
  87. data/lib/terrazzo/generator_helpers.rb +1 -1
  88. data/lib/terrazzo/has_many_pagination.rb +9 -1
  89. data/lib/terrazzo/namespace/resource.rb +30 -0
  90. data/lib/terrazzo/namespace.rb +26 -1
  91. data/lib/terrazzo/search.rb +29 -9
  92. data/lib/terrazzo/version.rb +1 -1
  93. data/lib/terrazzo.rb +1 -0
  94. data/terrazzo.gemspec +11 -1
  95. metadata +40 -6
  96. data/lib/generators/terrazzo/install/templates/application.json.props.erb +0 -17
  97. data/lib/generators/terrazzo/views/templates/fields/FieldRenderer.jsx +0 -103
  98. data/lib/generators/terrazzo/views/templates/fields/index.js +0 -81
@@ -1,10 +1,39 @@
1
1
  import React from "react";
2
2
 
3
- import { FieldRenderer } from "terrazzo/fields";
4
- import { Button } from "terrazzo/ui";
3
+ import { FieldRenderer } from "../fields";
4
+ import { Button } from "../components/ui";
5
5
 
6
- export function AdminForm({ form, errors }) {
7
- const { props: formProps, extras, fieldGroups, fields } = form;
6
+ export function AdminForm({ form = {}, errors = [] }) {
7
+ const { props: formProps = {}, extras = {}, fieldGroups = [], fields = [] } = form;
8
+ const groups = Array.isArray(fieldGroups) ? fieldGroups : [];
9
+ const hasFieldGroups = groups.length > 0;
10
+
11
+ const renderField = (field) => {
12
+ const hintId = field.hint && field.input?.id ? `${field.input.id}_hint` : null;
13
+ const input = hintId ?
14
+ { ...field.input, "aria-describedby": hintId } :
15
+ field.input;
16
+
17
+ return (
18
+ <div key={field.attribute} className="space-y-2">
19
+ <FieldRenderer
20
+ mode="form"
21
+ fieldType={field.fieldType}
22
+ value={field.value}
23
+ options={field.options}
24
+ attribute={field.attribute}
25
+ label={field.label}
26
+ hint={field.hint}
27
+ input={input}
28
+ required={field.required} />
29
+ {field.hint && (
30
+ <p id={hintId ?? undefined} className="text-sm text-muted-foreground">
31
+ {field.hint}
32
+ </p>
33
+ )}
34
+ </div>
35
+ );
36
+ };
8
37
 
9
38
  return (
10
39
  <>
@@ -26,44 +55,22 @@ export function AdminForm({ form, errors }) {
26
55
  <input key={hiddenProps.name} {...hiddenProps} />
27
56
  )}
28
57
 
29
- {fieldGroups ? (
58
+ {hasFieldGroups ? (
30
59
  <div className="space-y-8">
31
- {fieldGroups.map((group, groupIndex) =>
60
+ {groups.map((group, groupIndex) =>
32
61
  <fieldset key={groupIndex} className="space-y-6">
33
62
  {group.name && (
34
63
  <legend className="text-lg font-semibold">{group.name}</legend>
35
64
  )}
36
65
  <div className="space-y-6">
37
- {group.fields.map((field) =>
38
- <FieldRenderer
39
- key={field.attribute}
40
- mode="form"
41
- fieldType={field.fieldType}
42
- value={field.value}
43
- options={field.options}
44
- attribute={field.attribute}
45
- label={field.label}
46
- input={field.input}
47
- required={field.required} />
48
- )}
66
+ {(group.fields || []).map((field) => renderField(field))}
49
67
  </div>
50
68
  </fieldset>
51
69
  )}
52
70
  </div>
53
71
  ) : (
54
72
  <div className="space-y-6">
55
- {fields.map((field) =>
56
- <FieldRenderer
57
- key={field.attribute}
58
- mode="form"
59
- fieldType={field.fieldType}
60
- value={field.value}
61
- options={field.options}
62
- attribute={field.attribute}
63
- label={field.label}
64
- input={field.input}
65
- required={field.required} />
66
- )}
73
+ {fields.map((field) => renderField(field))}
67
74
  </div>
68
75
  )}
69
76
 
@@ -1,5 +1,15 @@
1
- json.array! Terrazzo::Namespace.new(namespace).resources_with_index_route do |nav_resource|
2
- json.label nav_resource.resource_name.humanize.pluralize
3
- json.path url_for(controller: "/#{nav_resource.controller_path}", action: :index, only_path: true)
4
- json.active nav_resource.controller_path == controller_path
1
+ resources = Terrazzo::Namespace.new(namespace).navigation_resources
2
+ navigation_groups = resources.group_by(&:navigation_group).map do |label, items|
3
+ { label: label, items: items }
4
+ end
5
+
6
+ json.array! navigation_groups do |group|
7
+ json.label group[:label]
8
+ json.items do
9
+ json.array! group[:items] do |nav_resource|
10
+ json.label nav_resource.navigation_label
11
+ json.path url_for(controller: "/#{nav_resource.controller_path}", action: :index, only_path: true)
12
+ json.active nav_resource.controller_path == controller_path
13
+ end
14
+ end
5
15
  end
@@ -3,7 +3,8 @@ import { useContent } from "@thoughtbot/superglue";
3
3
 
4
4
  import { getLayout } from "terrazzo";
5
5
  import { AdminForm } from "./_form";
6
- import { Button, Card, CardContent } from "terrazzo/ui";
6
+ import { CollectionToolbarActions } from "../components";
7
+ import { Button, Card, CardContent } from "../components/ui";
7
8
 
8
9
  export default function AdminEdit() {
9
10
  const Layout = getLayout();
@@ -11,10 +12,10 @@ export default function AdminEdit() {
11
12
  pageTitle,
12
13
  form,
13
14
  errors,
15
+ layoutActions,
14
16
  indexPath,
15
17
  showPath,
16
- navigation,
17
- resourceName
18
+ navigation
18
19
  } = useContent();
19
20
 
20
21
  return (
@@ -22,7 +23,8 @@ export default function AdminEdit() {
22
23
  navigation={navigation}
23
24
  title={pageTitle}
24
25
  actions={
25
- <div className="flex gap-2">
26
+ <div className="flex flex-wrap items-center gap-2">
27
+ <CollectionToolbarActions actions={layoutActions} />
26
28
  {showPath &&
27
29
  <a href={showPath} data-sg-visit>
28
30
  <Button variant="outline" size="sm">Cancel</Button>
@@ -2,16 +2,21 @@ import React from "react";
2
2
  import { useContent } from "@thoughtbot/superglue";
3
3
 
4
4
  import { getLayout } from "terrazzo";
5
- import { SearchBar, Pagination } from "terrazzo/components";
5
+ import { SearchBar, CollectionFilters, Pagination, CollectionToolbarActions } from "../components";
6
6
  import { AdminCollection } from "./_collection";
7
- import { Button } from "terrazzo/ui";
7
+ import { Button } from "../components/ui";
8
8
 
9
9
  export default function AdminIndex() {
10
10
  const Layout = getLayout();
11
11
  const {
12
12
  table,
13
13
  searchBar,
14
+ filters,
14
15
  pagination,
16
+ layoutActions,
17
+ toolbarActions,
18
+ bulkActions,
19
+ emptyState,
15
20
  navigation,
16
21
  newResourcePath,
17
22
  resourceName,
@@ -22,17 +27,24 @@ export default function AdminIndex() {
22
27
  <Layout
23
28
  navigation={navigation}
24
29
  title={resourceName}
25
- actions={newResourcePath &&
26
- <a href={newResourcePath} data-sg-visit>
27
- <Button size="sm">New {singularResourceName}</Button>
28
- </a>
30
+ actions={
31
+ <div className="flex flex-wrap items-center gap-2">
32
+ <CollectionToolbarActions actions={layoutActions} />
33
+ <CollectionToolbarActions actions={toolbarActions} />
34
+ {newResourcePath && (
35
+ <a href={newResourcePath} data-sg-visit>
36
+ <Button size="sm">New {singularResourceName}</Button>
37
+ </a>
38
+ )}
39
+ </div>
29
40
  }>
30
41
 
31
- <div className="flex items-center">
42
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
32
43
  <SearchBar {...searchBar} />
44
+ <CollectionFilters {...filters} />
33
45
  </div>
34
46
 
35
- <AdminCollection table={table} />
47
+ <AdminCollection table={table} emptyState={emptyState} bulkActions={bulkActions} />
36
48
 
37
49
  <Pagination {...pagination} />
38
50
  </Layout>);
@@ -3,7 +3,8 @@ import { useContent } from "@thoughtbot/superglue";
3
3
 
4
4
  import { getLayout } from "terrazzo";
5
5
  import { AdminForm } from "./_form";
6
- import { Button, Card, CardContent } from "terrazzo/ui";
6
+ import { CollectionToolbarActions } from "../components";
7
+ import { Button, Card, CardContent } from "../components/ui";
7
8
 
8
9
  export default function AdminNew() {
9
10
  const Layout = getLayout();
@@ -11,9 +12,9 @@ export default function AdminNew() {
11
12
  pageTitle,
12
13
  form,
13
14
  errors,
15
+ layoutActions,
14
16
  indexPath,
15
- navigation,
16
- resourceName
17
+ navigation
17
18
  } = useContent();
18
19
 
19
20
  return (
@@ -21,9 +22,12 @@ export default function AdminNew() {
21
22
  navigation={navigation}
22
23
  title={pageTitle}
23
24
  actions={
24
- <a href={indexPath} data-sg-visit>
25
+ <div className="flex flex-wrap items-center gap-2">
26
+ <CollectionToolbarActions actions={layoutActions} />
27
+ <a href={indexPath} data-sg-visit>
25
28
  <Button variant="outline" size="sm">Cancel</Button>
26
29
  </a>
30
+ </div>
27
31
  }>
28
32
 
29
33
  <Card>
@@ -2,8 +2,9 @@ import React from "react";
2
2
  import { useContent } from "@thoughtbot/superglue";
3
3
 
4
4
  import { getLayout } from "terrazzo";
5
- import { FieldRenderer } from "terrazzo/fields";
6
- import { Button, Card, CardContent, CardHeader, CardTitle } from "terrazzo/ui";
5
+ import { FieldRenderer } from "../fields";
6
+ import { CollectionToolbarActions } from "../components";
7
+ import { Card, CardContent, CardHeader, CardTitle } from "../components/ui";
7
8
 
8
9
  export default function AdminShow() {
9
10
  const Layout = getLayout();
@@ -11,47 +12,41 @@ export default function AdminShow() {
11
12
  pageTitle,
12
13
  attributes,
13
14
  attributeGroups,
15
+ layoutActions,
14
16
  editPath,
15
17
  deletePath,
16
18
  indexPath,
17
- resourceName,
18
19
  pluralResourceName,
19
20
  navigation
20
21
  } = useContent();
22
+ const resourceActions = [
23
+ indexPath && {
24
+ label: `Back to ${pluralResourceName}`,
25
+ url: indexPath,
26
+ variant: "outline",
27
+ },
28
+ editPath && {
29
+ label: "Edit",
30
+ url: editPath,
31
+ variant: "outline",
32
+ },
33
+ deletePath && {
34
+ label: "Delete",
35
+ url: deletePath,
36
+ method: "delete",
37
+ confirm: "Are you sure?",
38
+ variant: "destructive",
39
+ },
40
+ ].filter(Boolean);
21
41
 
22
42
  return (
23
43
  <Layout
24
44
  navigation={navigation}
25
45
  title={pageTitle}
26
46
  actions={
27
- <div className="flex gap-2">
28
- <a href={indexPath} data-sg-visit>
29
- <Button variant="outline" size="sm">Back to {pluralResourceName}</Button>
30
- </a>
31
- {editPath &&
32
- <a href={editPath} data-sg-visit>
33
- <Button variant="outline" size="sm">Edit</Button>
34
- </a>
35
- }
36
- {deletePath &&
37
- <form
38
- action={deletePath}
39
- method="post"
40
- data-sg-visit
41
- style={{ display: "inline" }}
42
- onSubmit={(e) => {
43
- if (!window.confirm("Are you sure?")) e.preventDefault();
44
- }}>
45
-
46
- <input type="hidden" name="_method" value="delete" />
47
- <input
48
- type="hidden"
49
- name="authenticity_token"
50
- value={document.querySelector('meta[name="csrf-token"]')?.content ?? ""} />
51
-
52
- <Button type="submit" variant="destructive" size="sm">Delete</Button>
53
- </form>
54
- }
47
+ <div className="flex flex-wrap items-center gap-2">
48
+ <CollectionToolbarActions actions={layoutActions} />
49
+ <CollectionToolbarActions actions={resourceActions} />
55
50
  </div>
56
51
  }>
57
52
 
@@ -69,7 +64,10 @@ export default function AdminShow() {
69
64
  return (
70
65
  <div key={key} className="py-4 grid grid-cols-3 gap-4">
71
66
  <dt className="text-sm font-medium text-muted-foreground">
72
- {attr.label}
67
+ <span>{attr.label}</span>
68
+ {attr.hint && (
69
+ <p className="mt-1 font-normal">{attr.hint}</p>
70
+ )}
73
71
  </dt>
74
72
  <dd className="col-span-2 text-sm">
75
73
  {attr.showPath ? (
@@ -27,20 +27,24 @@ module Terrazzo
27
27
  def create_ui_barrel
28
28
  create_file "app/views/#{namespace_name}/components/ui/index.js", <<~JS
29
29
  // Re-export all UI primitives from the terrazzo package.
30
- // To customize, run: rails g terrazzo:eject ui/<component_name>
30
+ // Ejected pages, fields, and components import this barrel.
31
+ // To customize those app-owned files, run: rails g terrazzo:eject ui/<component_name>
31
32
  export * from "terrazzo/ui";
32
33
  JS
33
34
  end
34
35
 
35
36
  def create_navigation_partial
36
37
  create_file "app/views/#{namespace_name}/application/_navigation.json.props", <<~RUBY
37
- resources = Terrazzo::Namespace.new(namespace).resources_with_index_route
38
+ resources = Terrazzo::Namespace.new(namespace).navigation_resources
39
+ navigation_groups = resources.group_by(&:navigation_group).map do |label, items|
40
+ { label: label, items: items }
41
+ end
38
42
 
39
- json.array! [{ label: "Resources", resources: resources }] do |group|
43
+ json.array! navigation_groups do |group|
40
44
  json.label group[:label]
41
45
  json.items do
42
- json.array! group[:resources] do |r|
43
- json.label r.resource_name.humanize.pluralize
46
+ json.array! group[:items] do |r|
47
+ json.label r.navigation_label
44
48
  json.path url_for(controller: "/\#{r.controller_path}", action: :index, only_path: true)
45
49
  json.active r.controller_path == controller_path
46
50
  end
@@ -1,3 +1,5 @@
1
+ require "set"
2
+
1
3
  module Terrazzo
2
4
  class BaseDashboard
3
5
  Field = Terrazzo::Field
@@ -66,16 +68,41 @@ module Terrazzo
66
68
  "#{resource.class.name} ##{resource.id}"
67
69
  end
68
70
 
71
+ def attribute_label(attribute, _context = nil)
72
+ attribute.to_s.humanize
73
+ end
74
+
75
+ def attribute_hint(_attribute, _context = nil)
76
+ nil
77
+ end
78
+
79
+ def collection_cell_options(_attribute, _resource)
80
+ {}
81
+ end
82
+
83
+ def collection_header_options(_attribute)
84
+ {}
85
+ end
86
+
87
+ def collection_row_options(_resource)
88
+ {}
89
+ end
90
+
69
91
  def collection_includes
70
- collection_attr_set = Set.new(collection_attributes)
92
+ includes_for_attributes(collection_attributes)
93
+ end
94
+
95
+ def includes_for_attributes(attributes)
96
+ attribute_set = Set.new(flatten_attributes(attributes))
71
97
  model = self.class.model
72
98
  attribute_types.each_with_object([]) do |(attr, type), includes|
73
- next unless collection_attr_set.include?(attr)
99
+ next unless attribute_set.include?(attr)
74
100
  next unless type.eager_load?
75
- has_association = model.reflect_on_association(attr)
76
- has_attachment = model.respond_to?(:reflect_on_attachment) && model.reflect_on_attachment(attr)
101
+ include_target = type.eager_load_association(attr, model)
102
+ has_association = model.reflect_on_association(include_target)
103
+ has_attachment = model.respond_to?(:reflect_on_attachment) && model.reflect_on_attachment(include_target)
77
104
  next unless has_association || has_attachment
78
- includes << attr
105
+ includes << include_target
79
106
  end
80
107
  end
81
108
 
@@ -84,6 +111,141 @@ module Terrazzo
84
111
  self.class::COLLECTION_FILTERS
85
112
  end
86
113
 
114
+ def collection_filter_options(_view = nil)
115
+ collection_filters.filter_map do |name, filter|
116
+ next if filter.respond_to?(:arity) && filter.arity > 1
117
+
118
+ {
119
+ label: collection_filter_label(name),
120
+ value: name.to_s,
121
+ }
122
+ end
123
+ end
124
+
125
+ def collection_filter_label(filter_name)
126
+ filter_name.to_s.humanize
127
+ end
128
+
129
+ def collection_item_actions(resource, view = nil)
130
+ return [] unless view
131
+
132
+ actions = []
133
+ if (path = view.collection_action_path(resource, :show))
134
+ actions << { label: "Show", url: path }
135
+ end
136
+ if (path = view.collection_action_path(resource, :edit))
137
+ actions << { label: "Edit", url: path }
138
+ end
139
+ if (path = view.collection_action_path(resource, :destroy))
140
+ actions << {
141
+ label: "Destroy",
142
+ url: path,
143
+ method: "delete",
144
+ confirm: "Are you sure?"
145
+ }
146
+ end
147
+ actions
148
+ end
149
+
150
+ def collection_toolbar_actions(view = nil)
151
+ return [] unless view && csv_export_enabled?
152
+
153
+ [
154
+ {
155
+ label: "Export CSV",
156
+ url: csv_export_url(view),
157
+ sg_visit: false,
158
+ },
159
+ ]
160
+ end
161
+
162
+ def collection_bulk_actions(_view = nil)
163
+ []
164
+ end
165
+
166
+ def layout_actions(_page, _view = nil, resource: nil)
167
+ []
168
+ end
169
+
170
+ def csv_export_enabled?
171
+ true
172
+ end
173
+
174
+ def csv_attributes
175
+ collection_attributes
176
+ end
177
+
178
+ def csv_filename
179
+ "#{self.class.resource_name.pluralize.parameterize}.csv"
180
+ end
181
+
182
+ def csv_value(_attribute, value, _resource)
183
+ case value
184
+ when nil
185
+ nil
186
+ when Hash
187
+ csv_hash_value(value)
188
+ when Array
189
+ value.map { |item| csv_value(nil, item, nil) }.join(", ")
190
+ else
191
+ value.to_s
192
+ end
193
+ end
194
+
195
+ def empty_collection_message
196
+ "No #{self.class.resource_name.pluralize.downcase} match the current view."
197
+ end
198
+
199
+ def navigation_label
200
+ self.class.const_defined?(:NAVIGATION_LABEL, false) ?
201
+ self.class::NAVIGATION_LABEL :
202
+ self.class.resource_name.pluralize
203
+ end
204
+
205
+ def navigation_group
206
+ self.class.const_defined?(:NAVIGATION_GROUP, false) ?
207
+ self.class::NAVIGATION_GROUP :
208
+ default_navigation_group
209
+ end
210
+
211
+ def navigation_order
212
+ self.class.const_defined?(:NAVIGATION_ORDER, false) ?
213
+ self.class::NAVIGATION_ORDER :
214
+ navigation_label
215
+ end
216
+
217
+ def navigation_group_order
218
+ self.class.const_defined?(:NAVIGATION_GROUP_ORDER, false) ?
219
+ self.class::NAVIGATION_GROUP_ORDER :
220
+ navigation_group
221
+ end
222
+
223
+ def show_in_navigation?
224
+ self.class.const_defined?(:SHOW_IN_NAVIGATION, false) ?
225
+ self.class::SHOW_IN_NAVIGATION :
226
+ true
227
+ end
228
+
229
+ private
230
+
231
+ def default_navigation_group
232
+ model_namespace = self.class.model.name.deconstantize
233
+ model_namespace.present? ? model_namespace.underscore.humanize.titleize : "Resources"
234
+ end
235
+
236
+ def csv_export_url(view)
237
+ query = view.request.query_parameters.except("_page", "per_page")
238
+ view.url_for(query.merge(format: :csv, only_path: true))
239
+ end
240
+
241
+ def csv_hash_value(value)
242
+ indifferent = value.with_indifferent_access
243
+ return indifferent[:display].to_s if indifferent.key?(:display)
244
+ return "#{indifferent[:count]} #{indifferent[:label]}" if indifferent.key?(:count) && indifferent.key?(:label)
245
+
246
+ JSON.generate(value)
247
+ end
248
+
87
249
  class << self
88
250
  def model
89
251
  name.chomp("Dashboard").constantize
@@ -0,0 +1,38 @@
1
+ require "csv"
2
+ require "json"
3
+
4
+ module Terrazzo
5
+ class CsvExport
6
+ attr_reader :dashboard, :resources
7
+
8
+ def initialize(dashboard, resources)
9
+ @dashboard = dashboard
10
+ @resources = resources
11
+ end
12
+
13
+ def to_csv
14
+ CSV.generate(headers: true) do |csv|
15
+ csv << attributes.map { |attr| header_for(attr) }
16
+
17
+ resources.each do |resource|
18
+ csv << attributes.map { |attr| value_for(resource, attr) }
19
+ end
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def attributes
26
+ @attributes ||= dashboard.flatten_attributes(dashboard.csv_attributes)
27
+ end
28
+
29
+ def header_for(attribute)
30
+ dashboard.attribute_label(attribute, :csv)
31
+ end
32
+
33
+ def value_for(resource, attribute)
34
+ field = dashboard.attribute_type_for(attribute).new(attribute, nil, :index, resource: resource)
35
+ dashboard.csv_value(attribute, field.serialize_value(:index), resource)
36
+ end
37
+ end
38
+ end
@@ -14,6 +14,13 @@ module Terrazzo
14
14
  private
15
15
 
16
16
  def display_name(record)
17
+ dashboard = dashboard_for(record.class)
18
+ return dashboard.display_resource(record) if dashboard && custom_display_resource?(dashboard)
19
+
20
+ fallback_display_name(record)
21
+ end
22
+
23
+ def fallback_display_name(record)
17
24
  if record.respond_to?(:display_name)
18
25
  record.display_name
19
26
  elsif record.respond_to?(:name)
@@ -44,16 +51,21 @@ module Terrazzo
44
51
  associated_class.all
45
52
  end
46
53
  pk = association_primary_key
47
- dashboard = associated_dashboard
48
- scope.map { |r| [dashboard ? dashboard.display_resource(r) : display_name(r), r.public_send(pk).to_s] }
54
+ scope.map { |r| [display_name(r), r.public_send(pk).to_s] }
49
55
  end
50
56
 
51
- def associated_dashboard
52
- "#{associated_class.name}Dashboard".constantize.new
57
+ def dashboard_for(klass)
58
+ return nil unless klass
59
+
60
+ "#{klass.name}Dashboard".constantize.new
53
61
  rescue NameError
54
62
  nil
55
63
  end
56
64
 
65
+ def custom_display_resource?(dashboard)
66
+ dashboard.method(:display_resource).owner != Terrazzo::BaseDashboard
67
+ end
68
+
57
69
  def association_primary_key
58
70
  reflection = resource&.class&.reflect_on_association(attribute)
59
71
  reflection&.association_primary_key || :id
@@ -83,6 +83,10 @@ module Terrazzo
83
83
  false
84
84
  end
85
85
 
86
+ def eager_load_association(attr, _model_class = nil)
87
+ attr.to_sym
88
+ end
89
+
86
90
  def associative?
87
91
  false
88
92
  end
@@ -46,8 +46,7 @@ module Terrazzo
46
46
  end
47
47
  scope = scope.reorder(options[:order]) if options[:order]
48
48
  pk = association_primary_key
49
- dashboard = associated_dashboard
50
- scope.map { |r| [dashboard ? dashboard.display_resource(r) : display_name(r), r.public_send(pk).to_s] }
49
+ scope.map { |r| [display_name(r), r.public_send(pk).to_s] }
51
50
  end
52
51
 
53
52
  def foreign_key_value
@@ -30,6 +30,10 @@ module Terrazzo
30
30
  deferred_class.eager_load?
31
31
  end
32
32
 
33
+ def eager_load_association(attr, model_class = nil)
34
+ deferred_class.eager_load_association(attr, model_class)
35
+ end
36
+
33
37
  def associative?
34
38
  deferred_class.associative?
35
39
  end