decidim-comments 0.0.1

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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +42 -0
  3. data/Rakefile +2 -0
  4. data/app/assets/config/decidim_comments_manifest.js +1 -0
  5. data/app/assets/javascripts/decidim/comments/bundle.js +504 -0
  6. data/app/assets/javascripts/decidim/comments/bundle.js.map +1 -0
  7. data/app/assets/javascripts/decidim/comments/comments.js.erb +8 -0
  8. data/app/commands/decidim/comments/create_comment.rb +40 -0
  9. data/app/forms/decidim/comments/comment_form.rb +16 -0
  10. data/app/frontend/application/apollo_client.js +16 -0
  11. data/app/frontend/application/application.component.jsx +37 -0
  12. data/app/frontend/application/application.component.test.jsx +33 -0
  13. data/app/frontend/application/icon.component.jsx +21 -0
  14. data/app/frontend/application/icon.component.test.jsx +43 -0
  15. data/app/frontend/comments/add_comment_form.component.jsx +250 -0
  16. data/app/frontend/comments/add_comment_form.component.test.jsx +173 -0
  17. data/app/frontend/comments/add_comment_form.mutation.graphql +8 -0
  18. data/app/frontend/comments/comment.component.jsx +202 -0
  19. data/app/frontend/comments/comment.component.test.jsx +125 -0
  20. data/app/frontend/comments/comment.fragment.graphql +12 -0
  21. data/app/frontend/comments/comment_data.fragment.graphql +11 -0
  22. data/app/frontend/comments/comment_order_selector.component.jsx +28 -0
  23. data/app/frontend/comments/comment_order_selector.component.test.jsx +9 -0
  24. data/app/frontend/comments/comment_thread.component.jsx +64 -0
  25. data/app/frontend/comments/comment_thread.component.test.jsx +71 -0
  26. data/app/frontend/comments/comment_thread.fragment.graphql +9 -0
  27. data/app/frontend/comments/comments.component.jsx +139 -0
  28. data/app/frontend/comments/comments.component.test.jsx +106 -0
  29. data/app/frontend/comments/comments.query.graphql +10 -0
  30. data/app/frontend/comments/featured_comment.component.jsx +23 -0
  31. data/app/frontend/comments/featured_comment.component.test.jsx +15 -0
  32. data/app/frontend/entry.js +24 -0
  33. data/app/frontend/entry.test.js +29 -0
  34. data/app/frontend/support/asset_url.js +11 -0
  35. data/app/frontend/support/generate_comments_data.js +29 -0
  36. data/app/frontend/support/generate_current_user_data.js +13 -0
  37. data/app/frontend/support/load_translations.js +23 -0
  38. data/app/frontend/support/require_all.js +10 -0
  39. data/app/frontend/support/resolve_graphql_query.js +37 -0
  40. data/app/frontend/support/stub_component.js +29 -0
  41. data/app/helpers/decidim/comments/comments_helper.rb +51 -0
  42. data/app/models/decidim/comments/comment.rb +55 -0
  43. data/app/types/decidim/comments/add_comment_type.rb +12 -0
  44. data/app/types/decidim/comments/author_type.rb +15 -0
  45. data/app/types/decidim/comments/comment_type.rb +28 -0
  46. data/config/i18n-tasks.yml +124 -0
  47. data/config/locales/ca.yml +35 -0
  48. data/config/locales/en.yml +36 -0
  49. data/config/locales/es.yml +35 -0
  50. data/db/migrate/20161130143508_create_comments.rb +11 -0
  51. data/db/migrate/20161214082645_add_depth_to_comments.rb +5 -0
  52. data/db/migrate/20161216102820_add_alignment_to_comments.rb +5 -0
  53. data/db/seeds.rb +11 -0
  54. data/lib/decidim/comments.rb +10 -0
  55. data/lib/decidim/comments/engine.rb +34 -0
  56. data/lib/decidim/comments/mutation_extensions.rb +36 -0
  57. data/lib/decidim/comments/query_extensions.rb +33 -0
  58. metadata +228 -0
@@ -0,0 +1,10 @@
1
+ query GetComments($commentableId: String!, $commentableType: String!) {
2
+ currentUser {
3
+ name
4
+ avatarUrl
5
+ }
6
+ comments(commentableId: $commentableId, commentableType: $commentableType) {
7
+ id
8
+ ...CommentThread
9
+ }
10
+ }
@@ -0,0 +1,23 @@
1
+ import { Component } from 'react';
2
+ import { I18n } from 'react-i18nify';
3
+
4
+ import Comment from './comment.component';
5
+
6
+ /**
7
+ * A wrapper component for a highlighted component.
8
+ * @class
9
+ * @augments Component
10
+ * @todo It's not used right now
11
+ */
12
+ export default class FeaturedComment extends Component {
13
+ render() {
14
+ return (
15
+ <section className="comments">
16
+ <h4 className="section-heading">{ I18n.t("components.featured_comment.title") }</h4>
17
+ <div className="comment-thread comment--pinned">
18
+ <Comment />
19
+ </div>
20
+ </section>
21
+ );
22
+ }
23
+ }
@@ -0,0 +1,15 @@
1
+ import { shallow } from 'enzyme';
2
+
3
+ import FeaturedComment from './featured_comment.component';
4
+ import Comment from './comment.component';
5
+
6
+ import stubComponent from '../support/stub_component';
7
+
8
+ describe('<FeaturedComment />', () => {
9
+ stubComponent(Comment);
10
+
11
+ it("should render a section of class comments", () => {
12
+ const wrapper = shallow(<FeaturedComment />);
13
+ expect(wrapper.find('section.comments')).to.be.present();
14
+ });
15
+ });
@@ -0,0 +1,24 @@
1
+ import ReactDOM from 'react-dom';
2
+
3
+ import loadTranslations from './support/load_translations';
4
+ import Comments from './comments/comments.component';
5
+
6
+ // Expose global components
7
+ window.DecidimComments.renderCommentsComponent = (nodeId, props) => {
8
+ var node = $(`#${nodeId}`)[0];
9
+
10
+ ReactDOM.render(
11
+ React.createElement(Comments,props),
12
+ node
13
+ );
14
+
15
+ function unmountComponent() {
16
+ ReactDOM.unmountComponentAtNode(node);
17
+ $(document).off('turbolinks:before-render', unmountComponent);
18
+ }
19
+
20
+ $(document).on('turbolinks:before-render', unmountComponent);
21
+ };
22
+
23
+ // Load component locales from yaml files
24
+ loadTranslations();
@@ -0,0 +1,29 @@
1
+ // ---------------------------------------
2
+ // Test Environment Setup
3
+ // ---------------------------------------
4
+ import sinon from 'sinon/pkg/sinon';
5
+ import chai from 'chai';
6
+ import sinonChai from 'sinon-chai';
7
+ import chaiAsPromised from 'chai-as-promised';
8
+ import chaiEnzyme from 'chai-enzyme';
9
+ import loadTranslations from './support/load_translations';
10
+ import requireAll from './support/require_all';
11
+
12
+ //
13
+ chai.use(sinonChai)
14
+ chai.use(chaiAsPromised)
15
+ chai.use(chaiEnzyme())
16
+ //
17
+ window.chai = chai
18
+ window.sinon = sinon
19
+ window.expect = chai.expect
20
+ window.should = chai.should()
21
+
22
+ // ---------------------------------------
23
+ // Require Tests
24
+ // ---------------------------------------
25
+ requireAll(require.context('./application/', true, /\.test\.jsx?$/));
26
+ requireAll(require.context('./comments/', true, /\.test\.jsx?$/));
27
+
28
+ // Load component locales from yaml files
29
+ loadTranslations();
@@ -0,0 +1,11 @@
1
+ const assetUrl = (name) => {
2
+ const url = window.DecidimComments.assets[name];
3
+
4
+ if (!url) {
5
+ throw new Error(`Asset "${name}" can't be found on decidim comments manifest.`);
6
+ }
7
+
8
+ return url;
9
+ };
10
+
11
+ export default assetUrl;
@@ -0,0 +1,29 @@
1
+ import { random, name, date, image } from 'faker/locale/en';
2
+
3
+ /**
4
+ * Generate random comment data to emulate a database real content
5
+ * @param {number} num - The number of comments to generate random data
6
+ * @returns {Object[]} - An array of objects representing comments data
7
+ */
8
+ const generateCommentsData = (num = 1) => {
9
+ let commentsData = [];
10
+
11
+ for (let idx = 0; idx < num; idx += 1) {
12
+ commentsData.push({
13
+ id: random.uuid(),
14
+ body: random.words(),
15
+ createdAt: date.past().toString(),
16
+ author: {
17
+ name: name.findName(),
18
+ avatarUrl: image.imageUrl()
19
+ },
20
+ replies: [],
21
+ canHaveReplies: true,
22
+ alignment: 0
23
+ })
24
+ }
25
+
26
+ return commentsData;
27
+ };
28
+
29
+ export default generateCommentsData;
@@ -0,0 +1,13 @@
1
+ import { name } from 'faker/locale/en';
2
+
3
+ /**
4
+ * Generate random current user data to emulate a database real content
5
+ * @returns {Object} - An object representing current user data
6
+ */
7
+ const generateCurrentUserData = () => {
8
+ return {
9
+ name: name.findName()
10
+ };
11
+ };
12
+
13
+ export default generateCurrentUserData;
@@ -0,0 +1,23 @@
1
+ /* eslint-disable no-param-reassign */
2
+ import { I18n } from 'react-i18nify';
3
+ import requireAll from './require_all';
4
+
5
+ /**
6
+ * Load components translations from yaml files and import them into
7
+ * react-i18ify system so they can be used via `I18n.t` method.
8
+ * @returns {Void} - Nothing
9
+ */
10
+ const loadTranslations = () => {
11
+ const translationsContext = require.context('../../../config/locales/', true, /\.yml$/);
12
+ const translationFiles = requireAll(translationsContext);
13
+
14
+ const translations = translationsContext.keys().reduce((acc, key, index) => {
15
+ const locale = key.match(/\.\/(.*)\.yml/)[1];
16
+ acc[locale] = translationFiles[index][locale].decidim;
17
+ return acc;
18
+ }, {});
19
+
20
+ I18n.setTranslations(translations);
21
+ };
22
+
23
+ export default loadTranslations;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Given a webpack require context it require all the files
3
+ * @param {Object} requireContext - A webpack require context
4
+ * @returns {Object[]} - An array of webpack modules
5
+ */
6
+ const requireAll = (requireContext) => {
7
+ return requireContext.keys().map(requireContext);
8
+ };
9
+
10
+ export default requireAll;
@@ -0,0 +1,37 @@
1
+ import graphql, { filter } from 'graphql-anywhere';
2
+
3
+ /**
4
+ * A simple resolver which returns object properties to easily
5
+ * traverse a graphql response
6
+ * @param {String} fieldName - An object property
7
+ * @param {Object} root - An object
8
+ * @returns {any} - An object's property value
9
+ */
10
+ const resolver = (fieldName, root) => root[fieldName];
11
+
12
+ /**
13
+ * A helper function to mock a graphql api request and return its
14
+ * result. The result can be filtered by the same query so it just
15
+ * returns a data subset.
16
+ * @param {String} document - A graphql query document
17
+ * @param {options} options - An object with optional options
18
+ * @returns {Object} - The result of the query filtered or not
19
+ */
20
+ const resolveGraphQLQuery = (document, options = {}) => {
21
+ const { filterResult, rootValue, context, variables } = options;
22
+
23
+ let result = graphql(
24
+ resolver,
25
+ document,
26
+ rootValue,
27
+ context,
28
+ variables
29
+ );
30
+
31
+ if (filterResult) {
32
+ return filter(document, result);
33
+ }
34
+ return result;
35
+ }
36
+
37
+ export default resolveGraphQLQuery;
@@ -0,0 +1,29 @@
1
+ /* eslint-disable no-param-reassign */
2
+
3
+ /**
4
+ * A helper function to stub the `propTypes` and `fragments` of a component.
5
+ * Useful for testing isolated components so the children propTypes are not
6
+ * evaluated.
7
+ * @param {ReactComponent} componentClass - A component constructor function or class
8
+ * @param {Object} options - An object which properties are used to stub component properties.
9
+ * @returns {ReactComponent} - A component with some properties stubbed
10
+ */
11
+ const stubComponent = function(componentClass, options = {}) {
12
+ let originalPropTypes = {};
13
+ let originalFragments = {};
14
+
15
+ beforeEach(function() {
16
+ originalPropTypes = componentClass.propTypes;
17
+ originalFragments = componentClass.fragments;
18
+
19
+ componentClass.propTypes = options.propTypes || {};
20
+ componentClass.fragments = options.fragments || {};
21
+ });
22
+
23
+ afterEach(function() {
24
+ componentClass.propTypes = originalPropTypes;
25
+ componentClass.fragments = originalFragments;
26
+ });
27
+ };
28
+
29
+ export default stubComponent;
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Comments
4
+ # A helper to expose the comments component for a commentable
5
+ module CommentsHelper
6
+ # Creates a Comments component which is rendered using `react_ujs`
7
+ # from react-rails gem
8
+ #
9
+ # resource - A commentable resource
10
+ # options - A hash of options (default: {})
11
+ # :arguable - A boolean value to indicate if tihs option is available
12
+ #
13
+ # Returns a div which contain a RectComponent to be rendered by `react_ujs`
14
+ def comments_for(resource, options = {})
15
+ commentable_type = resource.class.name
16
+ commentable_id = resource.id.to_s
17
+ node_id = "comments-for-#{commentable_type.demodulize}-#{commentable_id}"
18
+
19
+ react_comments_component(node_id, commentableType: commentable_type,
20
+ commentableId: commentable_id,
21
+ options: options.slice(:arguable),
22
+ locale: I18n.locale)
23
+ end
24
+
25
+ # Private: Render Comments component using inline javascript
26
+ #
27
+ # node_id - The id of the DOMElement to render the React component
28
+ # props - A hash corresponding to Comments component props
29
+ def react_comments_component(node_id, props)
30
+ content_tag("div", "", id: node_id) +
31
+ javascript_tag(%{
32
+ jQuery.ajax({
33
+ url: '#{asset_path("decidim/comments/comments.js")}',
34
+ dataType: 'script',
35
+ cache: true
36
+ }).then(function () {
37
+ window.DecidimComments.renderCommentsComponent(
38
+ '#{node_id}',
39
+ {
40
+ commentableType: "#{props[:commentableType]}",
41
+ commentableId: "#{props[:commentableId]}",
42
+ options: JSON.parse("#{j(props[:options].to_json)}"),
43
+ locale: "#{props[:locale]}"
44
+ }
45
+ );
46
+ });
47
+ })
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Comments
4
+ # Some resources will be configured as commentable objects so users can
5
+ # comment on them. The will be able to create conversations between users
6
+ # to discuss or share their thoughts about the resource.
7
+ class Comment < ApplicationRecord
8
+ # Limit the max depth of a comment tree. If C is a comment and R is a reply:
9
+ # C (depth 0)
10
+ # |--R (depth 1)
11
+ # |--R (depth 1)
12
+ # |--R (depth 2)
13
+ # |--R (depth 3)
14
+ MAX_DEPTH = 3
15
+
16
+ belongs_to :author, class_name: Decidim::User
17
+ belongs_to :commentable, polymorphic: true
18
+ has_many :replies, as: :commentable, class_name: Comment
19
+
20
+ validates :author, :commentable, :body, presence: true
21
+ validate :commentable_can_have_replies
22
+ validates :depth, numericality: { greater_than_or_equal_to: 0 }
23
+ validates :alignment, inclusion: { in: [0, 1, -1] }
24
+ validate :same_organization
25
+
26
+ before_save :compute_depth
27
+
28
+ delegate :organization, to: :commentable
29
+
30
+ # Public: Define if a comment can have replies or not
31
+ #
32
+ # Returns a bool value to indicate if comment can have replies
33
+ def can_have_replies?
34
+ depth < MAX_DEPTH
35
+ end
36
+
37
+ private
38
+
39
+ # Private: Check if commentable can have replies and if not adds
40
+ # a validation error to the model
41
+ def commentable_can_have_replies
42
+ errors.add(:commentable, :cannot_have_replies) if commentable.respond_to?(:can_have_replies?) && !commentable.can_have_replies?
43
+ end
44
+
45
+ # Private: Compute comment depth inside the current comment tree
46
+ def compute_depth
47
+ self.depth = commentable.depth + 1 if commentable.respond_to?(:depth)
48
+ end
49
+
50
+ def same_organization
51
+ errors.add(:commentable, :invalid) unless author.organization == organization
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Comments
4
+ # This type represents a mutation to create new comments.
5
+ AddCommentType = GraphQL::ObjectType.define do
6
+ name "Add comment"
7
+ description "Add a new comment"
8
+
9
+ field :comment, CommentType, "The new created comment"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Comments
4
+ # This type represents an author who owns a resource
5
+ AuthorType = GraphQL::ObjectType.define do
6
+ name "Author"
7
+ description "An author"
8
+
9
+ field :name, !types.String, "The user's name"
10
+ field :avatarUrl, !types.String, "The user's avatar url" do
11
+ resolve ->(obj, _args, _ctx) { obj.avatar.url }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Comments
4
+ # This type represents a comment on a commentable object.
5
+ CommentType = GraphQL::ObjectType.define do
6
+ name "Comment"
7
+ description "A comment"
8
+
9
+ field :id, !types.ID, "The Comment's unique ID"
10
+
11
+ field :body, !types.String, "The comment message"
12
+
13
+ field :createdAt, !types.String, "The creation date of the comment" do
14
+ property :created_at
15
+ end
16
+
17
+ field :author, !AuthorType, "The comment's author"
18
+
19
+ field :replies, !types[CommentType], "The comment's replies"
20
+
21
+ field :canHaveReplies, !types.Boolean, "Define if a comment can or not have replies" do
22
+ property :can_have_replies?
23
+ end
24
+
25
+ field :alignment, types.Int, "The comment's alignment. Can be 0 (neutral), 1 (in favor) or -1 (against)'"
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,124 @@
1
+ # i18n-tasks finds and manages missing and unused translations: https://github.com/glebm/i18n-tasks
2
+
3
+ # The "main" locale.
4
+ base_locale: en
5
+ ## All available locales are inferred from the data by default. Alternatively, specify them explicitly:
6
+ locales: [en]
7
+ ## Reporting locale, default: en. Available: en, ru.
8
+ # internal_locale: en
9
+
10
+ # Read and write translations.
11
+ data:
12
+ ## Translations are read from the file system. Supported format: YAML, JSON.
13
+ ## Provide a custom adapter:
14
+ # adapter: I18n::Tasks::Data::FileSystem
15
+
16
+ # Locale files or `File.find` patterns where translations are read from:
17
+ read:
18
+ ## Default:
19
+ # - config/locales/%{locale}.yml
20
+ ## More files:
21
+ # - config/locales/**/*.%{locale}.yml
22
+ ## Another gem (replace %#= with %=):
23
+ # - "<%#= %x[bundle show vagrant].chomp %>/templates/locales/%{locale}.yml"
24
+
25
+ # Locale files to write new keys to, based on a list of key pattern => file rules. Matched from top to bottom:
26
+ # `i18n-tasks normalize -p` will force move the keys according to these rules
27
+ write:
28
+ ## For example, write devise and simple form keys to their respective files:
29
+ # - ['{devise, simple_form}.*', 'config/locales/\1.%{locale}.yml']
30
+ ## Catch-all default:
31
+ # - config/locales/%{locale}.yml
32
+
33
+ ## Specify the router (see Readme for details). Valid values: conservative_router, pattern_router, or a custom class.
34
+ # router: convervative_router
35
+
36
+ yaml:
37
+ write:
38
+ # do not wrap lines at 80 characters
39
+ line_width: -1
40
+
41
+ ## Pretty-print JSON:
42
+ # json:
43
+ # write:
44
+ # indent: ' '
45
+ # space: ' '
46
+ # object_nl: "\n"
47
+ # array_nl: "\n"
48
+
49
+ # Find translate calls
50
+ search:
51
+ ## Paths or `File.find` patterns to search in:
52
+ # paths:
53
+ # - app/
54
+
55
+ ## Root directories for relative keys resolution.
56
+ # relative_roots:
57
+ # - app/controllers
58
+ # - app/helpers
59
+ # - app/mailers
60
+ # - app/presenters
61
+ # - app/views
62
+
63
+ ## Files or `File.fnmatch` patterns to exclude from search. Some files are always excluded regardless of this setting:
64
+ ## %w(*.jpg *.png *.gif *.svg *.ico *.eot *.otf *.ttf *.woff *.woff2 *.pdf *.css *.sass *.scss *.less *.yml *.json)
65
+ exclude:
66
+ - app/assets/images
67
+ - app/assets/fonts
68
+ - app/assets/javascripts/decidim/comments/bundle.js
69
+ - app/assets/javascripts/decidim/comments/bundle.js.map
70
+
71
+ ## Alternatively, the only files or `File.fnmatch patterns` to search in `paths`:
72
+ ## If specified, this settings takes priority over `exclude`, but `exclude` still applies.
73
+ # only: ["*.rb", "*.html.slim"]
74
+
75
+ ## If `strict` is `false`, guess usages such as t("categories.#{category}.title"). The default is `true`.
76
+ # strict: true
77
+
78
+ ## Multiple scanners can be used. Their results are merged.
79
+ ## The options specified above are passed down to each scanner. Per-scanner options can be specified as well.
80
+ ## See this example of a custom scanner: https://github.com/glebm/i18n-tasks/wiki/A-custom-scanner-example
81
+
82
+ ## Google Translate
83
+ # translation:
84
+ # # Get an API key and set billing info at https://code.google.com/apis/console to use Google Translate
85
+ # api_key: "AbC-dEf5"
86
+
87
+ ## Do not consider these keys missing:
88
+ ignore_missing:
89
+ - 'components.*'
90
+ # - 'errors.messages.{accepted,blank,invalid,too_short,too_long}'
91
+ # - '{devise,simple_form}.*'
92
+
93
+ ## Consider these keys used:
94
+ ignore_unused:
95
+ - 'activerecord.errors.messages.*'
96
+ - 'decidim.components.*'
97
+ # - '{devise,kaminari,will_paginate}.*'
98
+ # - 'simple_form.{yes,no}'
99
+ # - 'simple_form.{placeholders,hints,labels}.*'
100
+ # - 'simple_form.{error_notification,required}.:'
101
+
102
+ ## Exclude these keys from the `i18n-tasks eq-base' report:
103
+ # ignore_eq_base:
104
+ # all:
105
+ # - common.ok
106
+ # fr,es:
107
+ # - common.brand
108
+
109
+ ## Ignore these keys completely:
110
+ # ignore:
111
+ # - kaminari.*
112
+
113
+ ## Sometimes, it isn't possible for i18n-tasks to match the key correctly,
114
+ ## e.g. in case of a relative key defined in a helper method.
115
+ ## In these cases you can use the built-in PatternMapper to map patterns to keys, e.g.:
116
+ #
117
+ # <%#= I18n::Tasks.add_scanner 'I18n::Tasks::Scanners::PatternMapper',
118
+ # only: %w(*.html.haml *.html.slim),
119
+ # patterns: [['= title\b', '.page_title']] %>
120
+ #
121
+ # The PatternMapper can also match key literals via a special %{key} interpolation, e.g.:
122
+ #
123
+ # <%#= I18n::Tasks.add_scanner 'I18n::Tasks::Scanners::PatternMapper',
124
+ # patterns: [['\bSpree\.t[( ]\s*%{key}', 'spree.%{key}']] %>