decidim-comments 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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}']] %>