spree_posts 1.0.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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.md +9 -0
  3. data/README.md +59 -0
  4. data/Rakefile +23 -0
  5. data/app/controllers/spree/admin/post_categories_controller.rb +21 -0
  6. data/app/controllers/spree/admin/posts_controller.rb +41 -0
  7. data/app/finders/spree/posts/find.rb +137 -0
  8. data/app/helpers/spree/admin/posts_helper.rb +13 -0
  9. data/app/models/spree/post.rb +108 -0
  10. data/app/models/spree/post_category.rb +46 -0
  11. data/app/models/spree/store_decorator.rb +28 -0
  12. data/app/sorters/spree/posts/sort.rb +40 -0
  13. data/app/views/spree/admin/post_categories/_form.html.erb +6 -0
  14. data/app/views/spree/admin/post_categories/_table_header.html.erb +5 -0
  15. data/app/views/spree/admin/post_categories/_table_row.html.erb +9 -0
  16. data/app/views/spree/admin/post_categories/edit.html.erb +1 -0
  17. data/app/views/spree/admin/post_categories/index.html.erb +10 -0
  18. data/app/views/spree/admin/post_categories/new.html.erb +1 -0
  19. data/app/views/spree/admin/posts/_extra_actions.html.erb +1 -0
  20. data/app/views/spree/admin/posts/_form.html.erb +54 -0
  21. data/app/views/spree/admin/posts/_table_header.html.erb +7 -0
  22. data/app/views/spree/admin/posts/_table_row.html.erb +22 -0
  23. data/app/views/spree/admin/posts/edit.html.erb +5 -0
  24. data/app/views/spree/admin/posts/filters.html.erb +39 -0
  25. data/app/views/spree/admin/posts/index.html.erb +10 -0
  26. data/app/views/spree/admin/posts/new.html.erb +1 -0
  27. data/app/views/spree/admin/shared/_posts_tabs.html.erb +8 -0
  28. data/config/initializers/spree.rb +121 -0
  29. data/config/locales/en.yml +15 -0
  30. data/config/routes.rb +14 -0
  31. data/db/migrate/20250121160028_create_spree_posts_and_spree_post_categories.rb +33 -0
  32. data/lib/generators/spree_posts/install/install_generator.rb +20 -0
  33. data/lib/spree_posts/configuration.rb +13 -0
  34. data/lib/spree_posts/engine.rb +24 -0
  35. data/lib/spree_posts/factories.rb +28 -0
  36. data/lib/spree_posts/version.rb +7 -0
  37. data/lib/spree_posts.rb +12 -0
  38. metadata +117 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c7201669bde6f132f7a639b35712d2cb84fef08d14d683cffb1475d080b710b3
4
+ data.tar.gz: 27b670664530c0f01c555cfe6f337492c114b1c2d043742907aa3c23ccfd5b31
5
+ SHA512:
6
+ metadata.gz: f94ad0b4715aa0d9bbbf2952d5e333a3483739f715afcdefbff12dc05ac7535eb15bd0a3ceeaea4dfc2a4f04d3fd2a17a1712f0b828cfb431b3deae98a8ddf45
7
+ data.tar.gz: 0a059bd209b396729481e427c275ed2cc95c81d527161970e824ae694d215de95709035e696e2e1c1cc1d93208b1aafc8d6a4ee33fb72287427f3853c4074ea9
data/LICENSE.md ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vendo Connect Inc., Vendo Sp. z o.o.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # Spree Posts
2
+
3
+ This is a Posts extension for [Spree Commerce](https://spreecommerce.org)
4
+
5
+ ## Installation
6
+
7
+ 1. Add this extension to your Gemfile with this line:
8
+
9
+ ```ruby
10
+ bundle add spree_posts
11
+ ```
12
+
13
+ 2. Run the install generator
14
+
15
+ ```ruby
16
+ bundle exec rails g spree_posts:install
17
+ ```
18
+
19
+ 3. Restart your server
20
+
21
+ If your server was running, restart it so that it can find the assets properly.
22
+
23
+ ## Developing
24
+
25
+ 1. Create a dummy app
26
+
27
+ ```bash
28
+ bundle update
29
+ bundle exec rake test_app
30
+ ```
31
+
32
+ 2. Add your new code
33
+ 3. Run tests
34
+
35
+ ```bash
36
+ bundle exec rspec
37
+ ```
38
+
39
+ When testing your applications integration with this extension you may use it's factories.
40
+ Simply add this require statement to your spec_helper:
41
+
42
+ ```ruby
43
+ require 'spree_posts/factories'
44
+ ```
45
+
46
+ ## Releasing a new version
47
+
48
+ ```shell
49
+ bundle exec gem bump -p -t
50
+ bundle exec gem release
51
+ ```
52
+
53
+ For more options please see [gem-release README](https://github.com/svenfuchs/gem-release)
54
+
55
+ ## Contributing
56
+
57
+ If you'd like to contribute, please take a look at the
58
+ [instructions](CONTRIBUTING.md) for installing dependencies and crafting a good
59
+ pull request.
data/Rakefile ADDED
@@ -0,0 +1,23 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rspec/core/rake_task'
5
+ require 'spree/testing_support/extension_rake'
6
+
7
+ RSpec::Core::RakeTask.new
8
+
9
+ task :default do
10
+ if Dir['spec/dummy'].empty?
11
+ Rake::Task[:test_app].invoke
12
+ Dir.chdir('../../')
13
+ end
14
+ Rake::Task[:spec].invoke
15
+ end
16
+
17
+ desc 'Generates a dummy app for testing'
18
+ task :test_app do
19
+ ENV['LIB_NAME'] = 'spree_posts'
20
+ Rake::Task['extension:test_app'].execute(
21
+ install_admin: true
22
+ )
23
+ end
@@ -0,0 +1,21 @@
1
+ module Spree
2
+ module Admin
3
+ class PostCategoriesController < ResourceController
4
+ include Spree::Admin::TableConcern
5
+
6
+ add_breadcrumb Spree.t(:posts), :admin_posts_path
7
+ add_breadcrumb Spree.t(:categories), :admin_post_categories_path
8
+
9
+ def select_options
10
+ post_categories = current_store.post_categories.accessible_by(current_ability)
11
+ render json: post_categories.pluck(:id, :title).map { |id, title| { id: id, name: title } }
12
+ end
13
+
14
+ private
15
+
16
+ def permitted_resource_params
17
+ params.require(:post_category).permit(:title, :slug, :description)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,41 @@
1
+ module Spree
2
+ module Admin
3
+ class PostsController < ResourceController
4
+ include Spree::Admin::TableConcern
5
+
6
+ before_action :load_post_categories
7
+
8
+ add_breadcrumb Spree.t(:posts), :admin_posts_path
9
+
10
+ before_action :add_breadcrumb_for_post, only: [:edit, :update]
11
+
12
+ def select_options
13
+ render json: current_store.posts.published.accessible_by(current_ability).to_tom_select_json
14
+ end
15
+
16
+ private
17
+
18
+ def collection_includes
19
+ [:author, :post_category, :image_attachment]
20
+ end
21
+
22
+ def load_post_categories
23
+ @post_categories = current_store.post_categories.accessible_by(current_ability).order(:title)
24
+ end
25
+
26
+ def add_breadcrumb_for_post
27
+ return unless @post.present?
28
+
29
+ add_breadcrumb @post.title, spree.edit_admin_post_path(@post)
30
+ end
31
+
32
+ def permitted_resource_params
33
+ params.require(:post).permit(
34
+ :title, :meta_title, :meta_description, :slug, :author_id,
35
+ :post_category_id, :published_at, :content, :excerpt, :image,
36
+ tag_list: []
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,137 @@
1
+ module Spree
2
+ module Posts
3
+ class Find < Spree::BaseFinder
4
+ def initialize(scope:, params:)
5
+ super(scope: scope, params: params)
6
+ @scope = scope
7
+ @query = params[:q].presence&.strip
8
+ @ids = String(params.dig(:filter, :ids)).split(',')
9
+ @slugs = String(params.dig(:filter, :slugs)).split(',')
10
+ @category_ids = String(params.dig(:filter, :category_ids)).split(',')
11
+ @author_ids = String(params.dig(:filter, :author_ids)).split(',')
12
+ @tags = params.dig(:filter, :tags).to_s.split(',').compact_blank
13
+ @sort_by = params[:sort_by]
14
+ @published = params.dig(:filter, :published)
15
+ end
16
+
17
+ def execute
18
+ posts = by_ids(scope)
19
+ posts = by_slugs(posts)
20
+ posts = by_query(posts)
21
+ posts = by_category_ids(posts)
22
+ posts = by_author_ids(posts)
23
+ posts = by_tags(posts)
24
+ posts = by_published(posts)
25
+ posts = ordered(posts)
26
+
27
+ posts.distinct
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :scope, :query, :ids, :slugs, :category_ids, :author_ids, :tags, :sort_by, :published
33
+
34
+ def query?
35
+ query.present?
36
+ end
37
+
38
+ def ids?
39
+ ids.present?
40
+ end
41
+
42
+ def slugs?
43
+ slugs.present?
44
+ end
45
+
46
+ def category_ids?
47
+ category_ids.present?
48
+ end
49
+
50
+ def author_ids?
51
+ author_ids.present?
52
+ end
53
+
54
+ def tags?
55
+ tags.present?
56
+ end
57
+
58
+ def published?
59
+ published.present?
60
+ end
61
+
62
+ def sort_by?
63
+ sort_by.present?
64
+ end
65
+
66
+ def by_query(posts)
67
+ return posts unless query?
68
+
69
+ posts.search_by_title(query)
70
+ end
71
+
72
+ def by_ids(posts)
73
+ return posts unless ids?
74
+
75
+ posts.where(id: ids)
76
+ end
77
+
78
+ def by_slugs(posts)
79
+ return posts unless slugs?
80
+
81
+ posts.where(slug: slugs)
82
+ end
83
+
84
+ def by_category_ids(posts)
85
+ return posts unless category_ids?
86
+
87
+ posts.where(post_category_id: category_ids)
88
+ end
89
+
90
+ def by_author_ids(posts)
91
+ return posts unless author_ids?
92
+
93
+ posts.where(author_id: author_ids)
94
+ end
95
+
96
+ def by_tags(posts)
97
+ return posts if tags.empty?
98
+
99
+ posts.tagged_with(tags, any: true)
100
+ end
101
+
102
+ def by_published(posts)
103
+ return posts unless published?
104
+
105
+ case published.to_s
106
+ when 'true'
107
+ posts.published
108
+ when 'false'
109
+ posts.where(published_at: nil)
110
+ else
111
+ posts
112
+ end
113
+ end
114
+
115
+ def ordered(posts)
116
+ return posts unless sort_by?
117
+
118
+ case sort_by
119
+ when 'newest-first'
120
+ posts.by_newest
121
+ when 'oldest-first'
122
+ posts.order(created_at: :asc)
123
+ when 'published-newest'
124
+ posts.order(published_at: :desc)
125
+ when 'published-oldest'
126
+ posts.order(published_at: :asc)
127
+ when 'title-a-z'
128
+ posts.order(title: :asc)
129
+ when 'title-z-a'
130
+ posts.order(title: :desc)
131
+ else
132
+ posts
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,13 @@
1
+ module Spree
2
+ module Admin
3
+ module PostsHelper
4
+ def post_authors_select_options
5
+ current_store.users.map { |user| [user.name, user.id] }
6
+ end
7
+
8
+ def post_tags_json_array
9
+ Spree::Post.all_tags.map { |tag| { name: tag.name } }.to_json
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,108 @@
1
+ module Spree
2
+ class Post < Spree.base_class
3
+ has_prefix_id :post
4
+
5
+ include Spree::SingleStoreResource
6
+ include Spree::Metafields
7
+ extend FriendlyId
8
+
9
+ publishes_lifecycle_events
10
+
11
+ friendly_id :slug_candidates, use: %i[slugged scoped history], scope: %i[store_id deleted?]
12
+ acts_as_paranoid
13
+ acts_as_taggable_on :tags
14
+ acts_as_taggable_tenant :store_id
15
+
16
+ if defined?(PgSearch)
17
+ include PgSearch::Model
18
+ pg_search_scope :search_by_title, against: :title
19
+ else
20
+ scope :search_by_title, ->(query) { where('title LIKE ?', "%#{query}%") }
21
+ end
22
+
23
+ #
24
+ # Ransack filtering
25
+ #
26
+ self.whitelisted_ransackable_attributes = %w[author_id post_category_id published_at]
27
+ self.whitelisted_ransackable_associations = %w[author post_category]
28
+ self.whitelisted_ransackable_scopes = %w[search_by_title]
29
+
30
+ #
31
+ # Attachments
32
+ #
33
+ has_one_attached :image, service: Spree.public_storage_service_name
34
+
35
+ #
36
+ # Rich Text
37
+ #
38
+ has_rich_text :content
39
+ has_rich_text :excerpt
40
+
41
+ #
42
+ # Associations
43
+ #
44
+ belongs_to :author, class_name: Spree.admin_user_class.to_s, optional: true
45
+ belongs_to :store, class_name: 'Spree::Store', inverse_of: :posts
46
+ belongs_to :post_category, class_name: 'Spree::PostCategory', optional: true, touch: true, inverse_of: :posts
47
+ alias category post_category
48
+
49
+ #
50
+ # Validations
51
+ #
52
+ validates :title, :store, presence: true
53
+ validates :slug, presence: true, uniqueness: { scope: :store_id, conditions: -> { where(deleted_at: nil) } }
54
+ validates :meta_title, length: { maximum: 160 }, allow_blank: true
55
+ validates :meta_description, length: { maximum: 320 }, allow_blank: true
56
+ validates :image, content_type: Rails.application.config.active_storage.web_image_content_types
57
+
58
+ #
59
+ # Scopes
60
+ #
61
+ scope :published, -> { where(published_at: [..Time.current]) }
62
+ scope :by_newest, -> { order(created_at: :desc) }
63
+
64
+ delegate :name, to: :author, prefix: true, allow_nil: true
65
+ delegate :title, to: :post_category, prefix: true, allow_nil: true
66
+
67
+ def should_generate_new_friendly_id?
68
+ slug.blank? || (persisted? && title_changed?)
69
+ end
70
+
71
+ def slug_candidates
72
+ [
73
+ :title,
74
+ [:title, :id]
75
+ ]
76
+ end
77
+
78
+ def published?
79
+ published_at.present?
80
+ end
81
+
82
+ def publish(date = nil)
83
+ update(published_at: date || Time.current)
84
+ end
85
+
86
+ def unpublish
87
+ update(published_at: nil)
88
+ end
89
+
90
+ def description
91
+ excerpt.to_plain_text.presence || content.to_plain_text
92
+ end
93
+
94
+ def shortened_description
95
+ desc = excerpt.to_plain_text.presence || content.to_plain_text
96
+ desc.length > 320 ? "#{desc[0...320]}..." : desc
97
+ end
98
+
99
+ def self.to_tom_select_json
100
+ pluck(:id, :title).map do |id, title|
101
+ {
102
+ id: id,
103
+ name: title
104
+ }
105
+ end.as_json
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,46 @@
1
+ module Spree
2
+ class PostCategory < Spree.base_class
3
+ has_prefix_id :pcat
4
+
5
+ include Spree::SingleStoreResource
6
+ include Spree::Metafields
7
+ extend FriendlyId
8
+
9
+ publishes_lifecycle_events
10
+
11
+ friendly_id :slug_candidates, use: %i[slugged scoped history], scope: %i[store_id]
12
+
13
+ #
14
+ # Associations
15
+ #
16
+ belongs_to :store, class_name: 'Spree::Store', inverse_of: :post_categories
17
+ has_many :posts, class_name: 'Spree::Post', dependent: :nullify, inverse_of: :post_category
18
+
19
+ #
20
+ # Validations
21
+ #
22
+ validates :title, :store, presence: true
23
+ validates :slug, presence: true, uniqueness: { scope: :store_id }
24
+
25
+ #
26
+ # ActionText
27
+ #
28
+ has_rich_text :description
29
+
30
+ #
31
+ # Ransack
32
+ #
33
+ self.whitelisted_ransackable_attributes = %w[title slug]
34
+
35
+ def should_generate_new_friendly_id?
36
+ slug.blank? || title_changed?
37
+ end
38
+
39
+ def slug_candidates
40
+ [
41
+ :title,
42
+ [:title, :id]
43
+ ]
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,28 @@
1
+ module Spree
2
+ module StoreDecorator
3
+ def self.prepended(base)
4
+ base.has_many :posts, class_name: 'Spree::Post', dependent: :destroy, inverse_of: :store
5
+ base.has_many :post_categories, class_name: 'Spree::PostCategory', dependent: :destroy, inverse_of: :store
6
+
7
+ base.after_create :ensure_default_post_categories_are_created
8
+ end
9
+
10
+ private
11
+
12
+ def ensure_default_post_categories_are_created
13
+ Spree::Events.disable do
14
+ [
15
+ I18n.t('spree.default_post_categories.resources', default: 'Resources'),
16
+ I18n.t('spree.default_post_categories.articles', default: 'Articles'),
17
+ I18n.t('spree.default_post_categories.news', default: 'News')
18
+ ].each do |category_title|
19
+ next if post_categories.where(title: category_title).exists?
20
+
21
+ post_categories.create(title: category_title)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ Spree::Store.prepend(Spree::StoreDecorator)
@@ -0,0 +1,40 @@
1
+ module Spree
2
+ module Posts
3
+ class Sort < ::Spree::BaseSorter
4
+ def initialize(scope, params = {}, allowed_sort_attributes = [])
5
+ super(scope, params, allowed_sort_attributes)
6
+ end
7
+
8
+ def call
9
+ posts = by_param_attributes(scope)
10
+ posts = select_translatable_fields(posts) if Spree.use_translations?
11
+
12
+ posts.distinct
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :sort, :scope, :allowed_sort_attributes
18
+
19
+ # Add translatable fields to SELECT statement to avoid InvalidColumnReference error (workaround for Mobility issue #596)
20
+ def select_translatable_fields(scope)
21
+ translatable_fields = translatable_sortable_fields
22
+ return scope if translatable_fields.empty?
23
+
24
+ scope.i18n.select("#{Spree::Post.table_name}.*").select(*translatable_fields)
25
+ end
26
+
27
+ def translatable_sortable_fields
28
+ fields = []
29
+ Spree::Post.translatable_fields.each do |field|
30
+ fields << field if sort_by?(field.to_s)
31
+ end
32
+ fields
33
+ end
34
+
35
+ def sort_by?(field)
36
+ sort.detect { |s| s[0] == field }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,6 @@
1
+ <div class="card mb-6">
2
+ <div class="card-body">
3
+ <%= f.spree_text_field :title, required: true, autofocus: f.object.new_record? %>
4
+ <%= f.spree_rich_text_area :description %>
5
+ </div>
6
+ </div>
@@ -0,0 +1,5 @@
1
+ <tr>
2
+ <th scope="col"><%= Spree.t(:title) %></th>
3
+ <th scope="col"><%= Spree.t(:posts) %></th>
4
+ <th scope="col"></th>
5
+ </tr>
@@ -0,0 +1,9 @@
1
+ <tr data-controller="row-link" id="<%= dom_id(post_category) %>">
2
+ <td class="w-60 p-0">
3
+ <%= link_to post_category.title, spree.edit_admin_post_category_path(post_category), class: 'block w-full h-full px-4 py-2', data: { row_link_target: :link, turbo_frame: '_top' } %>
4
+ </td>
5
+ <td class="w-10 cursor-pointer" data-action="click->row-link#openLink"><%= post_category.posts.count %></td>
6
+ <td class="w-10 cursor-pointer text-right" data-action="click->row-link#openLink">
7
+ <%= link_to_edit(post_category, class: 'btn btn-light btn-sm', data: { turbo_frame: '_top' }) if can? :edit, post_category %>
8
+ </td>
9
+ </tr>
@@ -0,0 +1 @@
1
+ <%= render 'spree/admin/shared/edit_resource' %>
@@ -0,0 +1,10 @@
1
+ <%= render 'spree/admin/shared/posts_tabs' %>
2
+
3
+ <% content_for :page_actions do %>
4
+ <%= render_admin_partials(:post_categories_actions_partials) %>
5
+ <%= link_to_with_icon 'plus', Spree.t(:new_post_category), new_object_url, class: 'btn btn-primary' if can?(:create, Spree::PostCategory) %>
6
+ <% end %>
7
+
8
+ <%= render_admin_partials(:post_categories_header_partials) %>
9
+
10
+ <%= render_table @collection, :post_categories %>
@@ -0,0 +1 @@
1
+ <%= render 'spree/admin/shared/new_resource' %>
@@ -0,0 +1,54 @@
1
+ <div class="grid grid-cols-12 gap-6" data-controller="slug-form seo-form">
2
+ <div class="col-span-12 lg:col-span-8">
3
+ <div class="card mb-6">
4
+ <div class="card-body">
5
+ <%= f.spree_text_field :title,
6
+ required: true,
7
+ autofocus: true,
8
+ data: {
9
+ seo_form_target: 'sourceTitleInput',
10
+ slug_form_target: 'name',
11
+ action: 'slug-form#updateUrlFromName'
12
+ } %>
13
+
14
+ <%= f.spree_rich_text_area :content,
15
+ help_bubble: 'Add a summary of the post',
16
+ data: { seo_form_target: 'sourceDescriptionInput' } %>
17
+
18
+ <%= f.spree_rich_text_area :excerpt,
19
+ help_bubble: 'Add a summary of the post',
20
+ data: { seo_form_target: 'sourceExcerptInput' } %>
21
+ </div>
22
+ </div>
23
+ </div>
24
+
25
+ <div class="col-span-12 lg:col-span-4">
26
+ <div class="card mb-6">
27
+ <div class="card-body">
28
+ <%= f.spree_file_field :image, width: 1200, height: 600, crop: true, css: 'flex-col', label: Spree.t(:featured_image) %>
29
+ </div>
30
+ </div>
31
+
32
+ <div class="card mb-6">
33
+ <div class="card-body">
34
+ <%= f.spree_select :author_id, options_for_select(post_authors_select_options, @post.author_id || try_spree_current_user.id), { label: Spree.t(:author), autocomplete: true } %>
35
+ <%= f.spree_select :post_category_id, options_for_select(@post_categories.pluck(:title, :id), @post.post_category_id), { label: Spree.t(:category), include_blank: true, autocomplete: true } %>
36
+ <%= f.spree_datetime_field :published_at, help_bubble: 'Marks when the post will be published, leave it blank to hide it' %>
37
+
38
+ <div class="form-group mb-0">
39
+ <%= f.label :tag_list, Spree.t(:tags) %>
40
+ <%= tom_select_tag 'post[tag_list]', multiple: true, class: 'w-full', options: post_tags_json_array, active_option: @post.tag_list, value_field: :name, create: true %>
41
+ </div>
42
+ </div>
43
+ </div>
44
+
45
+ <%= render 'spree/admin/shared/seo',
46
+ f: f,
47
+ title: @post.title,
48
+ meta_title: @post.meta_title,
49
+ description: @post.content,
50
+ slug: @post.slug,
51
+ slug_path: 'posts',
52
+ placeholder: 'Add a title and content to see how this post might appear in a search engine listing' %>
53
+ </div>
54
+ </div>
@@ -0,0 +1,7 @@
1
+ <tr>
2
+ <th scope="col"><%= sort_link @search, :title, Spree.t(:title) %></th>
3
+ <th scope="col"><%= Spree.t(:category) %></th>
4
+ <th scope="col"><%= Spree.t(:author) %></th>
5
+ <th scope="col"><%= sort_link @search, :published_at, Spree.t(:published_at) %></th>
6
+ <th scope="col"></th>
7
+ </tr>
@@ -0,0 +1,22 @@
1
+ <tr data-controller="row-link" id="<%= dom_id(post) %>">
2
+ <td>
3
+ <%= link_to spree.edit_admin_post_path(post), class: 'no-underline flex items-center font-bold text-gray-900 gap-4', data: { row_link_target: :link, 'turbo-frame': '_top' } do %>
4
+ <% if post.image.attached? && post.image.variable? %>
5
+ <div class="admin-product-image-container">
6
+ <%= spree_image_tag(post.image, width: 100, height: 65, class: 'img-fluid', loading: :lazy) %>
7
+ </div>
8
+ <% else %>
9
+ <%= render 'spree/admin/shared/no_image', width: 100, height: 65 %>
10
+ <% end %>
11
+ <%= post.title %>
12
+ <% end %>
13
+ </td>
14
+
15
+ <td class="cursor-pointer" data-action="click->row-link#openLink"><%= post.post_category_title || '-' %></td>
16
+ <td class="cursor-pointer" data-action="click->row-link#openLink"><%= post.author_name %></td>
17
+ <td class="cursor-pointer" data-action="click->row-link#openLink"><%= post.published_at.present? ? spree_date(post.published_at) : '-' %></td>
18
+
19
+ <td class="actions">
20
+ <%= link_to_edit(post, class: 'btn btn-light btn-sm', data: { 'turbo-frame': '_top' }) if can? :edit, post %>
21
+ </td>
22
+ </tr>
@@ -0,0 +1,5 @@
1
+ <% content_for :page_actions_dropdown do %>
2
+ <%= external_page_preview_link(@post) %>
3
+ <% end %>
4
+
5
+ <%= render 'spree/admin/shared/edit_resource', col_class: 'col-span-12' %>
@@ -0,0 +1,39 @@
1
+ <%= search_form_for [:admin, @search], class: "filter-wrap", data: {controller: "filters dialog"} do |f| %>
2
+ <div class="flex flex-col lg:flex-row gap-2">
3
+ <%= render 'spree/admin/shared/filters_search_bar', param: :search_by_title, label: Spree.t(:title) %>
4
+
5
+ <%= button_tag type: 'button', class: 'btn btn-light flex items-center', data: { action: 'dialog#open' } do %>
6
+ <%= icon "adjustments", class: "mr-1" %>
7
+ <%= Spree.t("admin.filters") %>
8
+ <% end %>
9
+ </div>
10
+
11
+ <%= drawer(id: 'posts-filters-drawer', controller_name: 'dialog') do %>
12
+ <%= drawer_header(Spree.t(:filter), 'dialog') %>
13
+ <div class="drawer-body">
14
+ <div class="form-group">
15
+ <%= f.label :author_id_eq, Spree.t(:author) %>
16
+ <%= f.select :author_id_eq,
17
+ options_for_select(post_authors_select_options, params.dig(:q, :author_id_eq)),
18
+ { include_blank: true },
19
+ { data: { filters_target: :input, controller: 'autocomplete-select' } } %>
20
+ </div>
21
+ <div class="form-group">
22
+ <%= f.label :post_category_id_eq, Spree.t(:category) %>
23
+ <%= f.select :post_category_id_eq,
24
+ options_for_select(@post_categories.pluck(:title, :id), params.dig(:q, :post_category_id_eq)),
25
+ { include_blank: true },
26
+ { data: { filters_target: :input, controller: 'autocomplete-select' } } %>
27
+ </div>
28
+ <%= render_admin_partials(:posts_filters_partials, f: f) %>
29
+ </div>
30
+ <div class="drawer-footer">
31
+ <%= drawer_discard_button('dialog') %>
32
+ <%= render 'spree/admin/shared/filter_submit' %>
33
+ </div>
34
+ <% end %>
35
+
36
+ <%= render "spree/admin/shared/filter_badge_template" %>
37
+
38
+ <div data-filters-target="badgesContainer" class="filter-badges-container"></div>
39
+ <% end %>
@@ -0,0 +1,10 @@
1
+ <%= render 'spree/admin/shared/posts_tabs' %>
2
+
3
+ <% content_for :page_actions do %>
4
+ <%= render_admin_partials(:posts_actions_partials) %>
5
+ <%= link_to_with_icon 'plus', Spree.t(:new_post), new_object_url, class: 'btn btn-primary' if can?(:create, Spree::Post) %>
6
+ <% end %>
7
+
8
+ <%= render_admin_partials(:posts_header_partials) %>
9
+
10
+ <%= render_table @collection, :posts %>
@@ -0,0 +1 @@
1
+ <%= render 'spree/admin/shared/new_resource', col_class: 'col-span-12' %>
@@ -0,0 +1,8 @@
1
+ <% content_for :page_title do %>
2
+ <%= Spree.t(:posts) %>
3
+ <% end %>
4
+
5
+ <% content_for(:page_tabs) do %>
6
+ <%= nav_item(Spree.t(:posts), spree.admin_posts_path) if can?(:manage, Spree::Post) %>
7
+ <%= nav_item(Spree.t(:categories), spree.admin_post_categories_path) if can?(:manage, Spree::PostCategory) %>
8
+ <% end %>
@@ -0,0 +1,121 @@
1
+ Rails.application.config.after_initialize do
2
+ # Dependencies
3
+ Spree::Dependencies.posts_finder = 'Spree::Posts::Find'
4
+ Spree::Dependencies.posts_sorter = 'Spree::Posts::Sort'
5
+
6
+ if defined?(Spree::Api)
7
+ Spree::Api::Dependencies.storefront_posts_finder = 'Spree::Posts::Find'
8
+ Spree::Api::Dependencies.storefront_posts_sorter = 'Spree::Posts::Sort'
9
+ end
10
+
11
+ # Metafields
12
+ Rails.application.config.spree.metafields.enabled_resources.push(Spree::Post, Spree::PostCategory)
13
+
14
+ # Admin navigation
15
+ if defined?(Spree::Admin) && Spree.respond_to?(:admin)
16
+ sidebar_nav = Spree.admin.navigation.sidebar
17
+
18
+ sidebar_nav.add :posts,
19
+ label: :posts,
20
+ url: :admin_posts_path,
21
+ icon: 'article',
22
+ position: 70,
23
+ active: -> { %w[posts post_categories].include?(controller_name) },
24
+ if: -> { can?(:manage, Spree::Post) }
25
+
26
+ # Admin tables
27
+ Spree.admin.tables.register(:posts, model_class: Spree::Post, search_param: :title_cont)
28
+
29
+ Spree.admin.tables.posts.add :title,
30
+ label: :title,
31
+ type: :link,
32
+ sortable: true,
33
+ filterable: true,
34
+ default: true,
35
+ position: 10
36
+
37
+ Spree.admin.tables.posts.add :post_category,
38
+ label: :category,
39
+ type: :string,
40
+ filter_type: :autocomplete,
41
+ sortable: false,
42
+ filterable: true,
43
+ default: true,
44
+ position: 20,
45
+ method: ->(post) { post.post_category_title },
46
+ ransack_attribute: 'post_category_id',
47
+ search_url: ->(view_context) { view_context.spree.select_options_admin_post_categories_path(format: :json) }
48
+
49
+ Spree.admin.tables.posts.add :author,
50
+ label: :author,
51
+ type: :string,
52
+ filter_type: :autocomplete,
53
+ sortable: false,
54
+ filterable: true,
55
+ default: true,
56
+ position: 30,
57
+ method: ->(post) { post.author_name },
58
+ ransack_attribute: 'author_id',
59
+ search_url: ->(view_context) { view_context.spree.select_options_admin_admin_users_path(format: :json) }
60
+
61
+ Spree.admin.tables.posts.add :published_at,
62
+ label: :published_at,
63
+ type: :date,
64
+ sortable: true,
65
+ filterable: true,
66
+ default: true,
67
+ position: 40
68
+
69
+ Spree.admin.tables.posts.add :created_at,
70
+ label: :created_at,
71
+ type: :datetime,
72
+ sortable: true,
73
+ filterable: true,
74
+ default: false,
75
+ position: 50
76
+
77
+ Spree.admin.tables.posts.add :updated_at,
78
+ label: :updated_at,
79
+ type: :datetime,
80
+ sortable: true,
81
+ filterable: true,
82
+ default: false,
83
+ position: 60
84
+
85
+ # Post Categories Table
86
+ Spree.admin.tables.register(:post_categories, model_class: Spree::PostCategory, search_param: :title_cont)
87
+
88
+ Spree.admin.tables.post_categories.add :title,
89
+ label: :title,
90
+ type: :link,
91
+ sortable: true,
92
+ filterable: true,
93
+ default: true,
94
+ position: 10
95
+
96
+ Spree.admin.tables.post_categories.add :posts_count,
97
+ label: :posts,
98
+ type: :number,
99
+ sortable: false,
100
+ filterable: false,
101
+ default: true,
102
+ position: 20,
103
+ method: ->(post_category) { post_category.posts.count }
104
+
105
+ Spree.admin.tables.post_categories.add :created_at,
106
+ label: :created_at,
107
+ type: :datetime,
108
+ sortable: true,
109
+ filterable: true,
110
+ default: false,
111
+ position: 30
112
+
113
+ Spree.admin.tables.post_categories.add :updated_at,
114
+ label: :updated_at,
115
+ type: :datetime,
116
+ sortable: true,
117
+ filterable: true,
118
+ default: false,
119
+ position: 40
120
+ end
121
+ end
@@ -0,0 +1,15 @@
1
+ ---
2
+ en:
3
+ spree:
4
+ all_posts: All posts
5
+ all_posts_with_tag: All posts with tag
6
+ blog: Blog
7
+ blogs_posts: Blogs posts
8
+ default_post_categories:
9
+ articles: Articles
10
+ news: News
11
+ resources: Resources
12
+ new_post: New Post
13
+ new_post_category: New Post Category
14
+ post_categories: Post categories
15
+ posts: Posts
data/config/routes.rb ADDED
@@ -0,0 +1,14 @@
1
+ Spree::Core::Engine.add_routes do
2
+ namespace :admin do
3
+ resources :posts do
4
+ collection do
5
+ get :select_options, defaults: { format: :json }
6
+ end
7
+ end
8
+ resources :post_categories do
9
+ collection do
10
+ get :select_options, defaults: { format: :json }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,33 @@
1
+ class CreateSpreePostsAndSpreePostCategories < ActiveRecord::Migration[7.2]
2
+ def change
3
+ if !table_exists?(:spree_post_categories)
4
+ create_table :spree_post_categories do |t|
5
+ t.references :store, null: false, index: true
6
+ t.string :title, null: false
7
+ t.string :slug, null: false
8
+
9
+ t.timestamps
10
+
11
+ t.index ['slug', 'store_id'], name: 'index_spree_post_categories_on_slug_and_store_id', unique: true
12
+ end
13
+ end
14
+
15
+ if !table_exists?(:spree_posts)
16
+ create_table :spree_posts do |t|
17
+ t.references :author, index: true
18
+ t.datetime :published_at
19
+ t.string :title, null: false
20
+ t.string :slug, null: false
21
+ t.references :post_category, index: true
22
+ t.references :store, index: true
23
+ t.string :meta_title
24
+ t.string :meta_description
25
+
26
+ t.timestamps
27
+ t.datetime :deleted_at
28
+
29
+ t.index ['title'], name: 'index_spree_posts_on_title'
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ module SpreePosts
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ class_option :migrate, type: :boolean, default: true
5
+
6
+ def add_migrations
7
+ run 'bundle exec rake railties:install:migrations FROM=spree_posts'
8
+ end
9
+
10
+ def run_migrations
11
+ run_migrations = options[:migrate] || ['', 'y', 'Y'].include?(ask('Would you like to run the migrations now? [Y/n]'))
12
+ if run_migrations
13
+ run 'bin/rails db:migrate'
14
+ else
15
+ puts 'Skipping rails db:migrate, don\'t forget to run it!'
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ module SpreePosts
2
+ class Configuration < Spree::Preferences::Configuration
3
+
4
+ # Some example preferences are shown below, for more information visit:
5
+ # https://docs.spreecommerce.org/developer/contributing/creating-an-extension
6
+
7
+ # preference :enabled, :boolean, default: true
8
+ # preference :dark_chocolate, :boolean, default: true
9
+ # preference :color, :string, default: 'Red'
10
+ # preference :favorite_number, :integer
11
+ # preference :supported_locales, :array, default: [:en]
12
+ end
13
+ end
@@ -0,0 +1,24 @@
1
+ module SpreePosts
2
+ class Engine < Rails::Engine
3
+ require 'spree/core'
4
+ isolate_namespace Spree
5
+ engine_name 'spree_posts'
6
+
7
+ # use rspec for tests
8
+ config.generators do |g|
9
+ g.test_framework :rspec
10
+ end
11
+
12
+ initializer 'spree_posts.environment', before: :load_config_initializers do |_app|
13
+ SpreePosts::Config = SpreePosts::Configuration.new
14
+ end
15
+
16
+ def self.activate
17
+ Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/*_decorator*.rb')) do |c|
18
+ Rails.configuration.cache_classes ? require(c) : load(c)
19
+ end
20
+ end
21
+
22
+ config.to_prepare(&method(:activate).to_proc)
23
+ end
24
+ end
@@ -0,0 +1,28 @@
1
+ FactoryBot.define do
2
+ factory :post, class: Spree::Post do
3
+ store { Spree::Store.default || create(:store) }
4
+ post_category { association(:post_category, store: store) }
5
+ title { FFaker::Lorem.sentence }
6
+ content { FFaker::Lorem.paragraph }
7
+ published_at { Time.current }
8
+ association :author, factory: :admin_user
9
+
10
+ trait :with_image do
11
+ image { Rack::Test::UploadedFile.new(Spree::Core::Engine.root.join('spec/fixtures/thinking-cat.jpg'), 'image/jpeg') }
12
+ end
13
+
14
+ trait :published do
15
+ published_at { Time.current }
16
+ end
17
+
18
+ trait :unpublished do
19
+ published_at { nil }
20
+ end
21
+ end
22
+
23
+ factory :post_category, class: Spree::PostCategory do
24
+ sequence(:title) { |n| "Category ##{n + 1}" }
25
+ description { FFaker::Lorem.sentence }
26
+ store { Spree::Store.default || create(:store) }
27
+ end
28
+ end
@@ -0,0 +1,7 @@
1
+ module SpreePosts
2
+ VERSION = '1.0.0'.freeze
3
+
4
+ def gem_version
5
+ Gem::Version.new(VERSION)
6
+ end
7
+ end
@@ -0,0 +1,12 @@
1
+ require 'spree'
2
+ require 'spree_posts/engine'
3
+ require 'spree_posts/version'
4
+ require 'spree_posts/configuration'
5
+
6
+ module SpreePosts
7
+ mattr_accessor :queue
8
+
9
+ def self.queue
10
+ @@queue ||= Spree.queues.default
11
+ end
12
+ end
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spree_posts
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Vendo Connect Inc., Vendo Sp. z o.o.
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: spree
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 5.4.0.beta
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 5.4.0.beta
26
+ - !ruby/object:Gem::Dependency
27
+ name: spree_admin
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 5.4.0.beta
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 5.4.0.beta
40
+ - !ruby/object:Gem::Dependency
41
+ name: spree_dev_tools
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ email: hello@spreecommerce.org
55
+ executables: []
56
+ extensions: []
57
+ extra_rdoc_files: []
58
+ files:
59
+ - LICENSE.md
60
+ - README.md
61
+ - Rakefile
62
+ - app/controllers/spree/admin/post_categories_controller.rb
63
+ - app/controllers/spree/admin/posts_controller.rb
64
+ - app/finders/spree/posts/find.rb
65
+ - app/helpers/spree/admin/posts_helper.rb
66
+ - app/models/spree/post.rb
67
+ - app/models/spree/post_category.rb
68
+ - app/models/spree/store_decorator.rb
69
+ - app/sorters/spree/posts/sort.rb
70
+ - app/views/spree/admin/post_categories/_form.html.erb
71
+ - app/views/spree/admin/post_categories/_table_header.html.erb
72
+ - app/views/spree/admin/post_categories/_table_row.html.erb
73
+ - app/views/spree/admin/post_categories/edit.html.erb
74
+ - app/views/spree/admin/post_categories/index.html.erb
75
+ - app/views/spree/admin/post_categories/new.html.erb
76
+ - app/views/spree/admin/posts/_extra_actions.html.erb
77
+ - app/views/spree/admin/posts/_form.html.erb
78
+ - app/views/spree/admin/posts/_table_header.html.erb
79
+ - app/views/spree/admin/posts/_table_row.html.erb
80
+ - app/views/spree/admin/posts/edit.html.erb
81
+ - app/views/spree/admin/posts/filters.html.erb
82
+ - app/views/spree/admin/posts/index.html.erb
83
+ - app/views/spree/admin/posts/new.html.erb
84
+ - app/views/spree/admin/shared/_posts_tabs.html.erb
85
+ - config/initializers/spree.rb
86
+ - config/locales/en.yml
87
+ - config/routes.rb
88
+ - db/migrate/20250121160028_create_spree_posts_and_spree_post_categories.rb
89
+ - lib/generators/spree_posts/install/install_generator.rb
90
+ - lib/spree_posts.rb
91
+ - lib/spree_posts/configuration.rb
92
+ - lib/spree_posts/engine.rb
93
+ - lib/spree_posts/factories.rb
94
+ - lib/spree_posts/version.rb
95
+ homepage: https://github.com/spree/spree-posts
96
+ licenses:
97
+ - MIT
98
+ metadata: {}
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '3.2'
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements:
113
+ - none
114
+ rubygems_version: 4.0.2
115
+ specification_version: 4
116
+ summary: Spree Commerce Posts Extension
117
+ test_files: []