How the new Community site was built

Talk by Javier Gamarra for /dev/24 about the new Liferay Community Site (also called Questions), built using React + GraphQL.

So... where is it?

Ready to be launched [soon™]

How does it look?

Let's see...

Why a new site?

Personal opinions follow:

  • We want more participation of Liferay product teams in the forums
  • We need to improve feature request flow, listen more to the community
  • We want to ease interaction and participation
  • We need to do dog-fooding of our APIs

So how to we build it?

  • React... Why?
    • It started as a create-react app
  • GraphQL... Why?
  • Using Liferay MB APIs (MBMessage, MBThread...)

Talk about

  1. New view for MBoards

    1. Will be launched as such in the future (but hey, Open Source, it's there in the repo).
  2. Questions is a portlet*

    1. Just configuration, deal with ranks and create the URL for the file ItemSelector.
    2. Served inside Liferay with existing fetch -> no auth/CSRF management
  3. Headless use

    1. Uses permissions to avoid client permissions
    {answer.actions['reply-to-message'] && (
        <ClayButton
            className="text-reset"
            displayType="unstyled"
            onClick={() => setShowNewComment(true)}
        >
            {Liferay.Language.get('reply')}
        </ClayButton>
    )}
    1. Heavy use of GraphQL (around 40 queries, with fragments)*
    2. Heavy use of GraphQL relationships
     export const getMessagesQuery = gql`
     	query messageBoardThreadMessageBoardMessages(
     		$messageBoardThreadId: Long!
     		$page: Int!
     		$pageSize: Int!
     		$sort: String!
     	) {
     		messageBoardThreadMessageBoardMessages(
     			messageBoardThreadId: $messageBoardThreadId
     			page: $page
     			pageSize: $pageSize
     			sort: $sort
     		) {
     			items {
     				actions
     				aggregateRating {
     					ratingAverage
     					ratingCount
     					ratingValue
     				}
     				articleBody
     				creator {
     					id
     					image
     					name
     				}
     				creatorStatistics {
     					joinDate
     					lastPostDate
     					postsNumber
     					rank
     				}
     				encodingFormat
     				friendlyUrlPath
     				id
     				messageBoardMessages {
     					items {
     						actions
     						articleBody
     						creator {
     							id
     							image
     							name
     						}
     						encodingFormat
     						id
     						showAsAnswer
     					}
     				}
     				myRating {
     					ratingValue
     				}
     				showAsAnswer
     			}
     			pageSize
     			totalCount
     		}
     	}
     `;
    
    1. Including parent relationships
    export const getSectionBySectionTitleQuery = gql`
    	query messageBoardSections($filter: String!, $siteKey: String!) {
    		messageBoardSections(
    			filter: $filter
    			flatten: true
    			pageSize: 1
    			siteKey: $siteKey
    			sort: "title:asc"
    		) {
    			actions
    			items {
    				actions
    				id
    				messageBoardSections(sort: "title:asc") {
    					actions
    					items {
    						id
    						description
    						numberOfMessageBoardSections
    						numberOfMessageBoardThreads
    						parentMessageBoardSectionId
    						subscribed
    						title
    					}
    				}
    				numberOfMessageBoardSections
    				parentMessageBoardSection {
    					id
    					messageBoardSections {
    						items {
    							id
    							numberOfMessageBoardSections
    							parentMessageBoardSectionId
    							subscribed
    							title
    						}
    					}
    					numberOfMessageBoardSections
    					parentMessageBoardSectionId
    					subscribed
    					title
    				}
    				parentMessageBoardSectionId
    				subscribed
    				title
    			}
    		}
    	}
    `;
    
    1. We use MB* APIs + Ratings + Subscriptions
  4. API Changes

    1. No extension of APIs (remember that GraphQL APIs are inferred from REST + extended)

      1. We can add our API, and endpoint inside an existing API, change an existing endpoint, change a entity to add or remove fields, remove an endpoint...
      2. +do it only for GraphQL
      @GraphQLTypeExtension(Document.class)
      public class GetDocumentFolderTypeExtension {
      
          public GetDocumentFolderTypeExtension(Document document) {
              _document = document;
          }
      
          @GraphQLField(description = "Retrieves the document folder.")
          public DocumentFolder folder() throws Exception {
              return _applyComponentServiceObjects(
                  _documentFolderResourceComponentServiceObjects,
                  Query.this::_populateResourceContext,
                  documentFolderResource ->
                      documentFolderResource.getDocumentFolder(
                          _document.getDocumentFolderId()));
          }
      
          private Document _document;       
      }  
      public interface GraphQLContributor {
      
      	public default Object getMutation() {
      		return null;
      	}
      
      	public String getPath();
      
      	public default Object getQuery() {
      		return null;
      	}
      
      }
    2. New APIs: ranked information

  5. Uses Apollo JS Client with hooks (we were using a custom client before)

    const {data, loading} = useQuery(getUserActivityQuery, {
        variables: {filter: `creatorId eq ${creatorId}`, page, pageSize, siteKey,},
    });
    1. Not ideal
    2. Hooks move data transformation & variables to component
    3. A bit verbose
    4. Refetch + onComplete is wonky
    5. Hard to deal with conditional logic
    6. Cache is hit&miss, too many manual invalidation
    7. But I would say that is worth it, just for the times the syntax is brief and local cache alone
    8. Mixed usage with Apollo client directly
    9. Mixed/continuation calls are ugly so we avoid them:
    const [deleteMessage] = useMutation(deleteMessageQuery, {
        onCompleted() {
            if (comments && comments.length) {
                Promise.all(
                    comments.map(({id}) =>
                        client.mutate({
                            mutation: deleteMessageQuery,
                            variables: {messageBoardMessageId: id},
                        })
                    )
                ).then(() => {
                    deleteAnswer(answer);
                });
            }
            else {
                deleteAnswer(answer);
            }
        },
    });
  6. Works with old forum

    1. Compatible with old data and has to render a large forum (500.000? threads)
    2. Should-be compatible with BBCode and HTML but we are going the HTML way (conversion!)
    3. Support for old URLs with urlrewrite.xml + redirect components
    <rule>
        <from>^/web/guest/community/forums/message_boards(.{0,2000})$</from>
        <to type="permanent-redirect">%{context-path}/web/guest/community/forums/-/message_boards$1</to>
    </rule>
    1. +slugs for everything
    2. Uses CKEditor (conversion!). Very thin layer over Editor in frontend-editor-ckeditor-web (that is a very thin layer over CKEditor 4) + itemselector plugin.
    import CKEditor from 'ckeditor4-react';
    import React, {useEffect} from 'react';
    
    const BASEPATH = '/o/frontend-editor-ckeditor-web/ckeditor/';
    
    const Editor = React.forwardRef((props, ref) => {
        useEffect(() => {
            Liferay.once('beforeScreenFlip', () => {
                if (
                    window.CKEDITOR &&
                    Object.keys(window.CKEDITOR.instances).length === 0
                ) {
                    delete window.CKEDITOR;
                }
            });
        }, []);
    
        return <CKEditor ref={ref} {...props} />;
    });
    
    CKEditor.editorUrl = `${BASEPATH}ckeditor.js`;
    window.CKEDITOR_BASEPATH = BASEPATH;
    
    export {Editor};
    1. Uses Highlight JS
  7. Performance

    1. We are reviewing all the endpoints
    2. Elastic issues with big size windows... UX makes sense? || scrolling API || DB
    3. After small tuning (1 day) everything looks good (<1 sec load)
    4. NestedFields... future lazy implementation?
  8. Styles

    1. Some new UI patterns like breadcrumbs or lists
    2. But Clay everywhere (FTW!)

Tech debt

  • Very hacky implementation for subscriptions URLs
  • Some limitations regarding rankings APIs because DB structure
  • Use fragments in GraphQL calls !
  • MBCategoryMessageBoardSection management is messy
  • Add more friendlyURLs
  • Better use of related queries
  • Review accesibility :(

Questions?

  • ...
  • ...
  • ...

Thanks!