diff --git a/docs/entries.adoc b/docs/entries.adoc index c0845bd6..21a5706a 100644 --- a/docs/entries.adoc +++ b/docs/entries.adoc @@ -27,7 +27,13 @@ Entries are pretty much just standard JSX files, but they must follow *two requi 1. is located either in a folder either below *_/site/_* or below one of the *entryDirs* folders listed in _react4xp.properties_ (see also <>), 2. and *default-exports a function*: `props?=>reactComponent` - a function that _may_ take a `props` argument (serializable JS object: no functions) and _must_ return a react component. -NOTE: *Important:* if your entry uses react hooks or it's a react class component, read <<#classes-and-hooks, Classes and hooks in entries>> below. +[NOTE] +==== +*Important:* + +- There should be no call to `React.render` or `ReactDOM.render` in an entry (or a compiled entry or dependency bundle - keep an eye on your imports). React4xp will handle that call in the right context. +- If your entry uses react hooks or it's a react class component, read <<#classes-and-hooks, Classes and hooks in entries>> below. +==== {zwsp} + diff --git a/docs/guillotine.adoc b/docs/guillotine.adoc new file mode 100644 index 00000000..5a77f3db --- /dev/null +++ b/docs/guillotine.adoc @@ -0,0 +1,1348 @@ += Going Headless, part 1: Guillotine and graphQL +:toc: right +:imagesdir: media/ + +{zwsp} + + +image:guillotine.jpg[title="React4xp goes headless", width=600px] + +{zwsp} + + +== Some background + +In the previous chapters we've looked at a "traditional CMS" way of using react4xp: as content directly presented (and augmented) by react components, served together by XP. This is fairly easy to set up and works fine for many use cases. But in other cases, we may not want such a tight connection between the content and the presentational components. There may be advantages of handling/serving them separately - or an approach where a content-exposing API works alongside a traditional CMS, as a supplement. These approaches are often called a link:https://enonic.com/blog/headless-or-decoupled-cms[headless] or link:https://enonic.com/blog/what-is-hybrid-cms[hybrid] CMS. + +In Enonic XP, project link:https://developer.enonic.com/docs/headless-cms/stable[guillotine] is a go-to solution for extending the existing CMS capabilities. It exposes a read-only link:https://graphql.org/[GraphQL] API, and provides the ability to *read pure content data* (the "body" in the analogy) from XP with great flexibility and high performance. + +Combining this with react as the presentational component (the "head") opens up a range of ways to use XP in headless/hybrid manners. This makes guillotine a neat and natural companion to react4xp, so the *guillotine library is included in the react4xp starter* along with two extra helper wrappers. + +We will now look at how to use react4xp with guillotine in different ways, for different levels of decoupling. + +== Lesson overview + +This chapter will focus on setting up the guillotine API and the first usages: + +- set up a content type and react visualization for single movie items, +- making *a graphQL query* for content data in a regular XP controller, +- using react4xp to *visualize that data*, +- letting the rendered components make *the same query from the frontend*, +- and use react to dynamically render a visualization of the returned data, and this way fill in more content as we scroll down the page: an *"infinite scroller"* page. + +NOTE: The <> will expand on this lesson. It demonstrates more decoupled (and less XP-centric) ways to use these same react4xp components in a _standalone webapp_, to render content data from a guillotine API. + +[[chapter_source_files]] +=== Source files + +.Files involved (src/main/resources/...): +[source,files] +---- +react4xp/ + myEntries/ + Movie.jsx + MovieList.jsx + MovieList.scss + shared/ + Movie.jsx + Movie.scss + +site/ + content-types + /movie/ + movie.xml + parts/ + movie-list/ + movie-list.es6 + movie-list.xml + site.xml + +controllers/ + previewMovie.es6 + +headless/ + helpers/ + movieListRequests.es6 + guillotineApi.es6 + guillotineRequest.es6 + +---- +<1> We're going to build a site which is a list of movies, each displayed with a poster and a bit of info. The *entries* _Movie_ and _MovieList_ both import a _shared/Movie_ component. The _Movie_ entry uses it to preview a single movie item inside Content Studio, while the _MovieList_ entry displays the actual movie list site, by iterating over multiple _movie_ data items and using the _shared/Movie_ component for visualizing each item (both in a serverside-rendered and headless context). +<2> A content type for a single _movie_, +<3> A part with a controller that fetches child content items of the _movie_ content type, and renders them into MovieList, +<4> In _site.xml_ we will set up controller mappings for both the guillotine API and... +<5> ...the single-movie preview controller: displays a single movie without needing to set up a template and a part. +<6> _guillotineApi.es6_ is the actual API to guillotine. It can run graphQL queries both from XP controllers and through received HTTP requests. And _guillotineRequest.es6_ simplifies making such a request from the browser. Both of these are general-purpose and come with the starter (since version 1.1.0). But _helpers/movieListRequests.es6_ contains helper functions specific to the lesson site we're building here: it helps with building a query for fetching movie-list data, and parsing the returned data into the `props` format that the _Movie_ component needs. These helpers are also used on both frontend and backend. + + +{zwsp} + +{zwsp} + +{zwsp} + + + +== Groundwork: movie items + +This first stage should be easy enough, almost entirely repeating steps you've been through in previous chapters. We'll make a _movie_ content type, set up react4xp to preview-render it with react components (but with <<#controller_mapping, a little twist>>), and add some movie items that will be listed when the site is done. + +[NOTE] +==== +This entire chapter builds on the <>: _react4xp.properties_, _webpack.config.react4xp.js_ and the extra NPM packages should be set up like that. + +If you haven't completed that section already, better take a couple of minutes and do that before proceeding. +==== + +{zwsp} + + +=== Movie content type + +When the setup is ready, we'll start by adding a _movie_ *content type*, with an ImageSelector for a poster `image`, a simple HtmlArea with a movie `description`, a numeral Long field for adding the release `year` and an array of `actor` names: + +.site/content-types/movie/movie.xml: +[source,xml,options="nowrap"] +---- + + Movie + Moving images often reflecting culture + base:structured + +
+ + + + + + + + + * + Bold Italic Underline + + + + + + + + + + + + + +
+---- + +{zwsp} + + +=== React components + +Next, we'll set up a few react components for visualizing each movie item. + +The *entry*, _Movie.jsx_, will take care of rendering a preview of each movie content item in content studio later: + +.react4xp/myEntries/Movie.jsx: +[source,javascript,options="nowrap"] +---- +import React from 'react' + +import Movie from '../shared/Movie'; + +export default (props) => ; +---- + +This is a pure entry wrapper that just imports the next react component from _react4xp/shared_. + +Why import code from _shared_ instead of keeping it all in the entry? Firstly, it's a good rule of thumb to keep entries slim, for better optimization. And secondly, in addition to a Content Studio preview for single movies, we're going to use the *imported components* in the actual movie list too, for each single movie in the list. This way, the preview in Content Studio will always directly reflect what's displayed on the final page, because it's the same code that's used everywhere: + +.react4xp/shared/Movie.jsx: +[source,javascript,options="nowrap"] +---- +import React from 'react' + +import './Movie.scss'; + +const Cast = ({actors}) => ( +
    + { actors.map( actor =>
  • {actor}
  • ) } +
+); + + +const Info = ({heading, children}) => ( +
+ {heading ?

{heading}

: null} + {children} +
+); + + +const InfoContainer = ({title, year, description, actors}) => ( +
+

{title}

+ + +

{year}

+
+ + +
+
+
+ + { (actors && actors.length > 0) ? + + + : + null + } +
+); + +const Movie = ({imageUrl, title, description, year, actors}) => ( +
+ {`Movie + + +
+); + +export default Movie; +---- + +Not a lot of functionality here, just a JSX file that contains some structural units nested inside each other: the exported root level in the component, `Movie`, contains a movie poster image, and nests an `InfoContainer` component that displays the rest of the movie data. There, each movie data section is wrapped in an `Info` component (which just displays a header), and finally each actor name is mapped out in a list in the `Cast` component. + +Take a moment to note the *props signature* of _Movie.jsx_. `Movie` clearly expects the `imageUrl` prop to be a URL, so we'll need to handle the `image` field from the content type. Also, since the `description` prop will come from an HtmlArea in the content type it's already in working and pre-escaped HTML form, so we use the react functionality of `dangerouslySetInnerHTML` to insert it instead of just treating it as a string (which would just get escaped again). Next, `title` and `year` are expected to be simple strings (or numbers), and `actors` should be a string array. As you'll see, we'll make sure that each data readout of a movie item will be adapted to this signature, if needed. + +Moving on, _Movie.jsx_ also imports some *styling* that'll be handled by webpack the same way as in <>: + + +.react4xp/shared/Movie.scss: +[source,sass,options="nowrap"] +---- +html, body { + margin: 0; padding: 0; +} + +.infoContainer { + flex-grow: 1; flex-basis: content; padding: 0; margin: 0; + + * { + font-family: 'DejaVu Sans', Arial, Helvetica, sans-serif; color: #444; + } + + h2, h3 { + padding: 0; margin: 0; color: #0c0c0c; + } + + h2 { + font-size: 34px; + } + + p { + padding: 0; margin: 10px 0 0 0; + } +} + +.info { + margin: 0; padding: 30px 0 0 0; +} + + +.movie { + margin: 0; padding: 30px; box-sizing: border-box; width: 100%; display: flex; flex-flow: row nowrap; justify-content: flex-start; align-items: flex-start; +} + +.poster { + width: 300px; max-width: 30%; margin-right: 30px; flex: 0 1 auto; +} + +.cast { + list-style-type: none; margin: 0; padding: 0; + + .actor { + width: 100%; padding: 0; margin: 10px 0 0 0; + } +} + +---- + +{zwsp} + + +[[controller_mapping]] +=== Controller mapping + +Here comes a little variation: in this example, we want to connect a movie content item to with the rendering of the _Movie.jsx_ entry. But we don't want to mess around with setting up a <> the way we've done so far. Instead, we can use a link:https://developer.enonic.com/docs/xp/stable/cms/mappings[controller mapping] to make that connection in code. + +Let's open _site.xml_ and add a mapping: + +.site/site.xml: +[source,xml,options="nowrap"] +---- + + +
+ + + + + type:'com.enonic.app.react4xp:movie' + + + + + +---- + +Now, every _movie_ content item in Content Studio is always rendered with a particular controller: _/controllers/previewMovie.js_. + +Two important points when using a controller mapping like this: + +[NOTE] +==== +First, the controller reference in a mapping in _site.xml_ must always refer to *the runtime name of the controller*. In our case, the source file of our controller is _/controllers/previewMovie_ *_.es6_*, but at compile time, this is compiled into *_.js_* which is used at XP runtime. + +Second, controller mappings use qualified content type names that have *the name of the app* in it: `com.enonic.app.react4xp`. If/when you use a different name for your app, make sure to update content type references like this, e.g. `type:'my.awesome.app:movie'` +==== + +Now, with that mapping set up, we can add the _previewMovie_ controller: + +.controllers/previewMovie.es6: +[source,javascript,options="nowrap"] +---- +const util = require('/lib/util'); +const portal = require('/lib/xp/portal'); +const React4xp = require('/lib/enonic/react4xp'); + +exports.get = function(request) { + const content = portal.getContent(); + + const props = { + imageUrl: content.data.image ? + portal.imageUrl({ + id: content.data.image, + scale: 'width(300)' + }) : + undefined, + title: content.displayName, + description: portal.processHtml({ + value: content.data.description + }), + year: content.data.year, + actors: util.data.forceArray( content.data.actor ) + .map( actor => (actor || '').trim()) + .filter(actor => !!actor) + }; + + const id = content._id; + + const output = React4xp.render( + 'Movie', + props, + request, + { + id, + + body: ` + + + + ${content.displayName} + + +
+ + + ` + } + ); + + output.body = '' + output.body; + + return output; +}; +---- +After the previous chapters, not much in this controller should come as a surprise, but a quick overview anyway: + +<1> We use `getContent` to fetch the movie item data as usual (later, we'll use guillotine in a similar fashion. This doesn't matter as long as the props are constructed according to the signature of _Movie.jsx_). +<2> `image` comes from an ImageSelector and is just an image item ID, so we use `imageUrl` to get the URL that the prop signature expects. +<3> `description` comes from an HtmlArea, so we use `processHtml` to generate a finished HTML string for whatever content that might need it. Now it can be used with `dangerouslySetInnerHTML` in the react component. +<4> Normalizing the `actor` data to guarantee that it's an array. +<5> `React4xp.render` needs a unique ID to target a container in the surrounding `body`. +<6> `"Movie"` is of course the <> reference to the entry, _react4xp/myEntries/Movie.jsx_. +<7> This controller is the only one triggered for rendering _movie_ items. That means that the `body` that the rendering is inserted into, has to be a *full root HTML document* including a `` section (or otherwise react4xp won't know where to put the rendered page contributions, and the component won't work properly). +<8> Workaround for a current link:https://github.com/enonic/lib-react4xp/issues/107[inconvenient bug]. + + +{zwsp} + + +=== Make some Movies + +With all this in place, we're about to finish the groundwork stage: let's add some _movie_ content items to list. + +<>. + +Create a site content item and connect it to your app. Create some new Movie items: + +[.thumb] +image:edit_movie.png[title="Create a new movie item in content studio", width=1024px] + +{zwsp} + + +[[movies_in_container_site]] +It's important that *the new movies are inside/under _one common container item_ in the content hierarchy*. It's easiest for this lesson if the movie items are just directly under the site itself: + +[.thumb] +image:add_movies.png[title="Create some movie items to list, under the site", width=1024px] + +{zwsp} + + +When you mark/preview the site itself, you'll see no visualization yet. But previewing each movie item should now work as in the image above. + +Now we're ready to move on to more interesting stuff, using the content and code we just made. + + +{zwsp} + +{zwsp} + +{zwsp} + + + +== Static movie list + +Next, we'll make a page controller for a site item that displays a static list of the _movie_ items below it. The controller will use a configurable guillotine query to fetch an array of movie data items. + +{zwsp} + + +[[guillotine_helpers]] +=== Guillotine helpers and usage + +First off, an introduction to the guillotine helpers at we'll be using. Two of them - _headless/guillotineApi.es6_ and _headless/guillotineRequests.es6_ - are general-purpose helpers *included in the react4xp starter*, and the third one we'll write next. + +=== Included helper: guillotineApi.es6 + +The most central of the helpers and the first one we'll use, is *_headless/guillotineApi.es6_*. If we strip away a little boilerplate, the bare essence of it looks like this: + +.headless/guillotineApi.es6: +[source,javascript,options="nowrap"] +---- +const guillotineLib = require('/lib/guillotine'); +const graphQlLib = require('/lib/graphql'); + +const SCHEMA = guillotineLib.createSchema(); + +const executeQuery = (query, variables) => + graphQlLib.execute(SCHEMA, query, variables); + + + +// Use in XP controllers: +exports.executeQuery = executeQuery; + + +// Expose and use in POST requests from frontend: +exports.post = req => { + var body = JSON.parse(req.body); + + return { + contentType: 'application/json', + body: executeQuery(body.query, body.variables), + status: 200 + }; +}; +---- + +<1> At the core is the function `executeQuery`. Here, a guillotine `SCHEMA` definition is combined with a graphQL `query` string and an optional `variables` object. These are used with XP's graphQL library to `execute` the query. The result, a JSON object, is returned. +<2> `executeQuery` is exposed and directly usable from an XP controller. That's what we'll do next. +<3> a `post` function is also included for receiving POST requests from outside, e.g. a browser. If these requests contain a query string, it's executed with `executeQuery` above, and the result is returned in a response: basically a complete guillotine API endpoint for your webapp. + +NOTE: This endpoint is *disabled by default* in the starter, to encourage developers to consider security aspects before using it. We'll get back to that, and activate it, <<#expose_api, later>>. + +{zwsp} + + +The second included helper, <<#guillotine_request_helper, guillotineRequest.es6>>, is a `fetch` wrapper to simplify guillotine requests at the frontend. We'll take a look at that later. + +{zwsp} + + +=== Domain-specific helper for listing movies + +In order to make requests for a list of movies below a container item in the content hierarchy, we'll need a specific guillotine query string, as well as functionality to adapt the resulting data into the proper props structure for the react components. + +And by using the same code on the frontend and backend, for this too, we gain a bit of isomorphism (the predictability of a single source of truth, in short). So we'll make *a module with custom helper functionality* for our use case, and import from it in both places. + +Let's go ahead an write this: + +.headless/helpers/movieListRequests.es6: +[source,javascript,options="nowrap"] +---- +// Used by both backend and frontend (the movie-list part controller, and react4xp/entries/MovieList.jsx) + + +export const buildQueryListMovies = () => ` +query( + $first:Int!, + $offset:Int!, + $sort:String!, + $parentPathQuery:String! +) { + guillotine { + query( + contentTypes: ["com.enonic.app.react4xp:movie"], + query: $parentPathQuery, + first: $first, + offset: $offset, + sort: $sort + ) { + ... on com_enonic_app_react4xp_Movie { + _id + displayName + data { + year + description + actor + image { + ... on media_Image { + imageUrl(type: absolute, scale: "width(300)") + } + } + } + } + } + } +}`; + + +export const buildParentPathQuery = (parentPath) => `_parentPath = '/content${parentPath}'`; + + +// Not using util-lib, to ensure usability on frontend +const forceArray = maybeArray => Array.isArray(maybeArray) + ? maybeArray + : maybeArray + ? [maybeArray] + : []; + + +export const extractMovieArray = responseData => responseData.data.guillotine.query + .filter( movieItem => movieItem && typeof movieItem === 'object' && Object.keys(movieItem).indexOf('data') !== -1) + .map( + movieItem => ({ + id: movieItem._id, + title: movieItem.displayName.trim(), + imageUrl: movieItem.data.image.imageUrl, + year: movieItem.data.year, + description: movieItem.data.description, + actors: forceArray(movieItem.data.actor) + .map( actor => (actor || '').trim()) + .filter(actor => !!actor) + }) + ); + +export default {}; +---- +<1> The function `buildQueryListMovies` returns a string: a *guillotine query* ready to use in the API. Colloquially, you can read this query in 3 parts: ++ +- The parenthesis after the first `query` declares some parameters that are required (`!`) as values in a `variables` object together with the query. ++ +- In the parenthesis after the second `query`, those `variables` values are used: this query will list a certain number (`$first`) of movie items (`contentTypes: ["com.enonic.app.react4xp:movie"]`), starting at index number `$offset`, and sort them using the sort expression string `$sort`. It narrows down the search by nesting a second and specifying query expression `$parentPathQuery`, that tells guillotine to only look below a certain parent path in the content hierarchy - see below (2.). ++ +- The last major block, `... on com_enonic_app_react4xp_Movie {`, asks for a selection of sub-data from each found movie item: `_id`, `displayName`, `data.year`, etc. Note the second `... on media_Image` block nested inside it: instead of returning the ID value in the `data.image` field, we pass that through an `imageUrl` function that gives us a finished `data.imageUrl` field instead - directly and in one single query. ++ +NOTE: Again, remember that this query hardcodes qualified names to a content type, that contain the name of the app: `com.enonic.app.react4xp:movie` and `com_enonic_app_react4xp_Movie`. Change these if your app name is not `com.enonic.app.react4xp`. ++ +For more about guillotine queries, see the link:https://developer.enonic.com/docs/headless-cms/2.x/api[guillotine API documentation]. +<2> The function `buildParentPathQuery` returns a *sub-query string* needed to only search below the content path of a container item: the parameter `$parentPathQuery` in the main query string (1.), inserted through the `variables` object. ++ +In <<#movies_in_container_site, the example above>>, the site _MovieSite_ is the item that contains the movies, and the content hierarchy in Content Studio shows us that _MovieSite_ has the content path `/moviesite`. So the sub-query that directs guillotine to only search for movies below that parent item, can be made like this: `buildParentPathQuery('/moviesite')`. +<3> The function `extractMovieArray` takes the data object of a full guillotine search result and adapts it to the data structure that matches the props structure of our react components: an array of objects, where each object is a movie item. + +{zwsp} + + +=== Part controller + +Armed with these helpers, we can build an XP part controller that runs a guillotine query, extracts movie props from it, and renders a list of movies. We can even let the part's config control how the movies are listed: + +.site/parts/movie-list/movie-list.xml +[source,xml,options="nowrap"] +---- + + + Movie List + View a list of movies + + + + + + + 5 + + + + + + + + + + + createdTime + + + + + + + +---- + +[[movie-list-part-controller]] +The actual controller: + +.site/parts/movie-list/movie-list.es6: +[source,javascript,options="nowrap"] +---- +const portal = require('/lib/xp/portal'); +const React4xp = require('/lib/enonic/react4xp'); + +const guillotine = require('/headless/guillotineApi'); +const { buildQueryListMovies, buildParentPathQuery, extractMovieArray } = require('/headless/helpers/movieListRequests'); + + +exports.get = function(request) { + const content = portal.getContent(); + const component = portal.getComponent(); + + const sortExpression = `${component.config.sortBy} ${ + component.config.descending ? 'DESC' : 'ASC' + }`; + + const query = buildQueryListMovies(); + + const variables = { + first: component.config.movieCount, + offset: 0, + sort: sortExpression, + parentPathQuery: buildParentPathQuery(content._path) + }; + + const guillotineResult = guillotine.executeQuery(query, variables); + + const movies = extractMovieArray(guillotineResult); + + return React4xp.render( + 'MovieList', + { + movies, + apiUrl: `./${portal.getSite()._path}/api/headless`, + parentPath: content._path, + movieCount: component.config.movieCount, + sortExpression + }, + request + ); +}; +---- +<1> Import the functionality from the helpers that were <<#guillotine_helpers, just described>>, +<2> Use the part's config to build a sort expression for the query, +<3> Get the query string, +<4> Build the `variables` object with the query's parameters (what's up with a variable called `variables`, you ask? This is for consistencty - the guillotine lib and its docs refer to the encapsulated object of values for the various variables in the query, as an argument called `variables`. Now we have _that_ clarified), +<5> Execute the query string with the variables in the guillotine API, +<6> Extract `movies` props (an array of objects with the same signature as the props for _Movie.jsx_) from the result of the query, +<7> Render a _MovieList_ entry with the `movies` props (as well as some additional props that we will need later for making the same guillotine query from the frontend. Especially note the `apiUrl` prop: this is basically just the URL to the site itself, with `/api/headless` appended to it. When we later <<#expose_api, expose the guillotine API>> to the frontend, this is the URL to the API - specifically, the POST method in _guillotineApi.es6_). + +{zwsp} + + +[[static_movielist]] +=== React components + +We're still missing that *_MovieList_ entry* that will display the list of movie items: + +.react4xp/myEntries/MovieList.jsx: +[source,javascript,options="nowrap"] +---- +import React from 'react' + +import './MovieList.scss'; + +import Movie from "../shared/Movie"; + +const MovieList = ({movies, apiUrl, parentPath, movieCount, sortExpression}) => { + + return ( +
+ {movies + ? movies.map(movie => + + ) + : null + } +
+ ); +}; + +// MUST use this export line wrapping, because of a useState hook later. +export default (props) => ; +---- +The only notable things here: + +- A lot of the props aren't used yet, just the `movies` array. The rest of the props are a preparation for later. +- Each item object in the array in `movies` is just mapped onto an imported _shared/Movie.jsx_ component: the same react component that's used to render the movie previews in Content Studio. + +Most of the styling is already handled at the single-movie level, so just a minimum of extra *list styling* is needed: + +.react4xp/myEntries/MovieList.scss: +[source,sass,options="nowrap"] +---- +.movieList { + margin: 0 auto; width: 1024px; max-width: 100%; + + .movie { + border-bottom: 1px dotted #ccc; + } +} +---- + +{zwsp} + + +=== Render the list + +We can now set up the parent site with the movies, with a _movie-list_ part. Rebuild the app, enter/refresh Content Studio, and make the _movie-list_ part handle the visualization of the _MovieSite_ item. + +TIP: You can either do that <> to render _all_ sites with this part controller. Or better, edit _MovieSite_ directly and add the _movie-list_ part to the region there, the same way as when adding a part to the region of a template. With this last direct-edit approach, only _MovieSite_ will be rendered like this; other sites won't. + +Correctly set up, you can now select the list in the edit panel, and a part config panel will appear on the right. *Edit the config fields to control the guillotine query*: how many movies should be rendered, and in what order? + +[.thumb] +image:movie-list-part-config.png[title="Edit the movie-list part configuration to control the guillotine query", width=1024px] + +{zwsp} + + +TIP: As usual, click Preview to see the rendering in a tab of its own. A preview browser tab, with the page inspector and server log open on the side, is also the best starting point to hunt down bugs in the visualization. + +{zwsp} + +{zwsp} + +{zwsp} + + +== Making the list dynamic + +In this next section we'll expose the API to the frontend and let the client send a request to it. The returned data will be merged into the component state of the _MovieList_ entry, and used to render the new movies into the page DOM. Finally, we'll add a scroll listener to trigger the process. + +{zwsp} + + +[[expose_api]] +=== Exposing the guillotine API + +The `post` method in the included _guillotineApi.es6_ is nearly ready to use. All it needs to be activated for API requests from outside, is a controller mapping. We'll add that next to the mapping <<#controller_mapping, we've already added>>. + +But first, a word of caution about doing this in other projects: + +[NOTE] +==== +In the included form from the react4xp starter, _guillotineAPI.es6_ is as bare-bone as it gets, and primarily meant as a stepping stone for developers to expand from. + +Guillotine is a read-only interface, but still: after adding the controller mapping to an unchanged _guillotineAPI.es6_, it's opened to receiving and *executing any guillotine query* from the frontend, technically exposing any data from the content repo to being read. + +Before using it in production, *it's highly recommended to implement your own security measures* in/around _guillotineAPI.es6_. For example authorization/permissions-checking/filtering what data is available/keeping the actual query string on the backend and only exposing the `variables` object, etc - depending on your environment and use case. +==== + +For the purpose of running this lesson on your localhost, though, it should be perfectly fine. Enter _site.xml_ again to add the controller mapping: + +.site/site.xml: +[source,xml,options="nowrap"] +---- + + +
+ + + type:'com.enonic.app.react4xp:movie' + + + + + /api/headless + + + + + +---- + +After rebuilding, the API is now up and running at `/api/headless` (e.g. `http://localhost:8080/admin/site/preview/default/draft/moviesite/api/headless`). + +TIP: If you want to try it out right now, REPL-style and without needing to create the frontend code first, there's a cool tool for that: the <<#graphql_playground, GraphQL Playground>>. + + +{zwsp} + + +[[guillotine_request_helper]] +=== Included helper: guillotineRequest.es6 + +Time to add some code to the existing _MovieList.jsx_ so it can fetch data from the guillotine endpoint. To easily get started with that, we'll use the second helper module *included in the react4xp starter*: _headless/guillotineRequest.es6_ +(the first of the two helpers is of course <<#guillotine_helpers, guillotineApi.es6>>). + +This too has some convenience error handling and boilerplate like default parameter values/functions, but if we skip that, the bare essence is a `fetch` wrapper: + +.headless/guillotineRequest.es6: +[source,javascript,options="nowrap"] +---- +const doGuillotineRequest = ({ + url, + query, + variables, + handleResponseErrorFunc, + extractDataFunc, + handleDataFunc, + catchErrorsFunc +}) => { + + fetch( + url, + { + method: "POST", + body: JSON.stringify({ + query, + variables} + ), + credentials: "same-origin", + } + ) + .then(handleResponseErrorFunc) + .then(response => response.json()) + .then(extractDataFunc) + .then(handleDataFunc) + .catch(catchErrorsFunc) +}; + +export default doGuillotineRequest; +---- +In short, run `doGuillotineRequest(params)` where `params` is an object that has at least a `.url` and a `.query` attribute (and optional `.variables`), and it will send the query to the guillotine API and handle the returned data (or errors). How that's handled is up to callbacks in `params`. + +*Full `params` specs are:* + +<1> `url` (string, mandatory): URL to the API endpoint, i.e. to the controller mapping of `headless/guillotineApi.es6`: `/api/headless`. +<2> `query` (string, mandatory): valid link:https://developer.enonic.com/docs/headless-cms/2.x/api[guillotine query] string. +<3> `variables` (object, optional): corresponds to the guillotine `variables` object: key-value pairs where the keys correspond to parameters in the `query` string. E.g. the value of `variables.first` will be inserted into the query string as `$first`. +<4> `handleResponseErrorFunc` (function, optional): callback function that takes a response object and returns it, usually after having checked the response for errors and handled that. Default: just checks `response.status` for HTTP codes other than OK and throws any problems as `Error`. +<5> `extractDataFunc` (function, optional): callback function that takes a data object and returns another. After the response body has been parsed from JSON string to actual data, the data are run through this function, before being handled by `handleDataFunc`. Default: data is returned unchanged. +<6> `handleDataFunc` (function, optional but makes little sense to omit): callback function that takes a data object (curated data from guillotine) and does something with it - *this callback is pretty much what `doGuillotineRequest` is all about*. Default: do-nothing. +<7> `catchErrorsFunc` (function, optional): callback function that takes an error object and handles it. Default: console-error-logs the error message. + +{zwsp} + + +=== Frontend guillotine request + +Now we're ready to *add a guillotine call from the frontend*, specifically to _MovieList.jsx_. Here's what we'll do: + +- Focus on the guillotine request and just add a click listener that asks for the _next X movie items_ in the list after the ones that are displayed +- ...where X is the number of movies rendered to begin with. So if the _movie-list_ part is configured to do the first rendering from the controller with X=3 movies, the guillotine request in _MovieList.jsx_ will ask for data about the movies 4 through 6. Or in the language of our guillotine query: `first: 3, offset: 3`. +- It should also keep counting so that if we click one more time, it should ask for the next X movies _after_ the ones it previously found +- ...so that in the next query, `first:3, offset:6`, and then `first:3, offset:9`, etc. +- It should do this by keeping the `query` string stable and updating `variables` for each request. + +.react4xp/myEntries/MovieList.jsx: +[source,javascript,options="nowrap"] +---- +import React, { useState, useEffect } from 'react'; + +import './MovieList.scss'; + +import Movie from "../shared/Movie"; + + +import doGuillotineRequest from "../../headless/guillotineRequest"; +import { buildQueryListMovies, buildParentPathQuery, extractMovieArray } from "../../headless/helpers/movieListRequests"; + + +// State values that don't need re-rendering capability, but need to be synchronously read/writable across closures. +let nextOffset = 0; // Index for what will be the next movie to search for in a guillotine request + + +const MovieList = ({movies, apiUrl, parentPath, movieCount, sortExpression}) => { + + + // UseEffect with these arguments ( function, [] ) corresponds to componentDidMount in the old-school class-based react components, and only happens after the first time the component is rendered into the DOM. + useEffect( + ()=>{ + console.log("Initializing..."); + nextOffset = movieCount; + }, + [] + ); + + + // ------------------------------------------------------ + // Set up action methods, triggered by listener: + + + // Makes a (guillotine) request for data with these search parameters and passes an anonymous callback function as + // handleDataFunc (used on the returned list of movie data). + const makeRequest = () => { + console.log("Requesting", movieCount, "movies, starting from index", nextOffset); + doGuillotineRequest({ + url: apiUrl, + + query: buildQueryListMovies(), + + variables: { + first: movieCount, + offset: nextOffset, + sort: sortExpression, + parentPathQuery: buildParentPathQuery(parentPath) // + }, + + extractDataFunc: extractMovieArray, + + handleDataFunc: (newMovieItems) => { + console.log("Received data:", newMovieItems); + nextOffset += movieCount; + } + }); + }; + + // ------------------------------------------------------------------------------------ + // Actual rendering: + + return ( +
+ {movies + ? movies.map(movie => + + ) + : null + } +
+ ); +}; + +// MUST use this export line wrapping, because of the hooks we'll add later. +export default (props) => ; +---- +The changes are: + +<1> Import some link:https://reactjs.org/docs/hooks-overview.html[react hooks] to help us handle some component state and lifecycle events +<2> Import `doGuillotineRequest` described moments ago, and also the same helpers from _headless/helpers/movieListRequests.es6_ that we're already using <<#movie-list-part-controller, in the part controller>>. +<3> `nextOffset` keeps track of how far the guillotine requests have counted, or rather: what the first movie in the next request should be (the next `variables.offset`) +<4> We pass a callback function to `useEffect`, a react hook that (in this case, since the array after is empty) only calls the callback after the first time the component has been rendered. This way, `nextOffset` gets an initial value, only once. +<5> `makeRequest` is the function that triggers the behavior: +<6> `doGuillotineRequest` sends a request to the API at the prop `apiUrl`. +<7> `buildQueryListMovies` gives ut the same query string as in the part controller, +<8> The rest of the `props` from the controller are now used to build the `variables` object which are inserted as the parameters in the query. Except the `offset` parameter, which uses the _current value_ of the counting `nextOffset`, +<9> Just like in the controller, `buildParentPathQuery` uses the path of the movies' parent content to build a subquery variable, +<10> And also like in the controller, we use `extractMovieArray` to convert guillotine results to a data format that corresponds to an array of _Movie.jsx_ props - just by passing the function into `doGuillotineRequest` as the `extractDataFunc` parameter, +<11> And finally, when we the data has passed through `extractMovieArray` and we get some `newMovieItems`, we do a temporary action for now: console-log the data, and increase `nextOffset` with the initial number of movies, so it's ready for the next request. +<12> We add `onClick={makeRequest}` to the movie list DOM container element. Now, when we click the list, `makeRequest` is triggered, and the resulting data from the guillotine API is displayed in the browser log. + +{zwsp} + + +Rebuilding this and running the moviesite in a preview window and with a console open, and the clicking somewhere on the list, say 3 times, the result might look something like this (note the console messages, how the returned movie IDs are not the same between responses, and that "`starting from index...`" keeps counting): + +image:click-data.png[title="Console logs different data items from 3 mouse clicks", width=1024px] + +{zwsp} + + +=== Dynamic DOM updates + +With the request and the data flow in place, we're just a small step away from *rendering the returned movies* at the bottom of the page, effectively filling in new movies on the page for each click. + +React is _very_ eager to do this whenever a component state is updated, so we'll let it *render from the state* instead of directly from the `movie` prop: + +.react4xp/myEntries/MovieList.jsx: +[source,javascript,options="nowrap"] +---- +import React, { useState, useEffect } from 'react' + +import './MovieList.scss'; + +import Movie from "../shared/Movie"; + +import doGuillotineRequest from "../../headless/guillotineRequest"; +import { buildQueryListMovies, buildParentPathQuery, extractMovieArray } from "../../headless/helpers/movieListRequests"; + +// State values that don't need re-rendering capability, but need to be synchronously read/writable across closures. +let nextOffset = 0; // Index for what will be the next movie to search for in a guillotine request + + +const MovieList = ({movies, apiUrl, parentPath, movieCount, sortExpression}) => { + + + // Setup asynchronous component state that triggers re-render on change. + const [state, setState] = useState({ movies }); + + // UseEffect with these arguments ( function, [] ) corresponds to componentDidMount in the old-school class-based react components. + useEffect( + ()=>{ + console.log("Initializing..."); + + nextOffset = movieCount; + }, + [] + ); + + + // ------------------------------------------------------ + // Set up action methods, triggered by listener: + + // Makes a (guillotine) request for data with these search parameters and passes an anonymous callback function as + // handleDataFunc (used on the returned list of movie data). + const makeRequest = () => { + console.log("Requesting", movieCount, "movies, starting from index", nextOffset); + doGuillotineRequest({ + url: apiUrl, + + query: buildQueryListMovies(), + + variables: { + first: movieCount, + offset: nextOffset, + sort: sortExpression, + parentPathQuery: buildParentPathQuery(parentPath) + }, + + extractDataFunc: extractMovieArray, + + handleDataFunc: updateDOMWithNewMovies + }); + }; + + // When a movie data array is returned from the guillotine data request, this method is called. + const updateDOMWithNewMovies = (newMovieItems) => { + console.log("Received data:", newMovieItems); + if (newMovieItems.length > 0) { + console.log("Adding movies to state:", newMovieItems.map(movie => movie.title)); + + nextOffset += movieCount; + + // Use a function, not just a new direct object/array, for mutating state object/array instead of replacing it: + setState(oldState => ({ + movies: [ + ...oldState.movies, + ...newMovieItems + ] + })); + + console.log("Added new movies to state / DOM."); + } + }; + + + // ------------------------------------------------------------------------------------ + // Actual rendering: + + return ( +
+ {state.movies + ? state.movies.map(movie => + + ) + : null + } +
+ ); +}; + +// MUST use this export line wrapping, because of the useState hook. +export default (props) => ; +---- +Changes: + +<1> The `useState` react hook defines the component state: we pass the `movies` prop into it to set the initial state content. In return we get an array containing `state` - a handle for the _current_ state - and `setState` - a function that updates the state. +<2> We now want `doGuillotineRequest` to trigger the new function `updateDOMWithNewMovies` when the guillotine data is returned and curated. +<3> In `updateDOMWithNewMovies` we only keep counting the `nextOffset` if any movies were actually added. +<4> We call `setState` to update the state, so that the incoming items from guillotine are added after the old ones. ++ +[TIP] +==== +It would be possible to use `setState` with a new object instead of a function: + +`setState( { movies: [...state.movies, ...newMovieItems]})` + +But `setState` is an asynchronous function, and calling it with a "current state of affairs" as the argument runs the risk of introducing race conditions: we'd lose control of timing when the DOM updates, especially since we're going to combine that length-of-DOM with a continuously scrolling trigger. A callback function argument works around this: _"update the state based on what things are like whenever this function is called"_. +==== +<5> Use `state.movies` instead of just the `movies` props: now react will watch the state and automatically re-render the component as soon as the state is updated. + +{zwsp} + + +Rebuild the app, update the moviesite preview tab and try clicking on the list. New movies should appear below the existing one, expanding the movie list as you click: + +image:click-fill-dom.png[title="Each click fetches new movies and renders them", width=1024px] + +{zwsp} + + +=== Scroll listener + +We have arrived! The final step in this chapter: + +We'll finish _MovieList.jsx_ by replacing the click listener with a scroll listener. The scroll listener will check if the page has been scrolled almost all the way to the bottom (i.e. the bottom of the movie-list container is just a little bit below the bottom of the screen) and triggers the same procedure if it has. + +With one additional change to the procedure: the trigger should disable the scroll listener temporarily, only re-enabling it when we get some data back (or after a delay). This is to avoid flooding _guillotineApi.es6_ with requests - since scroll events are fast and numerous. + +.react4xp/myEntries/MovieList.jsx: +[source,javascript,options="nowrap"] +---- +import React, { useState, useEffect } from 'react' + +import './MovieList.scss'; + +import Movie from "../shared/Movie"; + +import doGuillotineRequest from "../../headless/guillotineRequest"; +import { buildQueryListMovies, buildParentPathQuery, extractMovieArray } from "../../headless/helpers/movieListRequests"; + +// State values that don't need re-rendering capability, but need to be synchronously read/writable across closures. +let nextOffset = 0; // Index for what will be the next movie to search for in a guillotine request + +let listenForScroll = true; +const TRIGGER_OFFSET_PX_FROM_BOTTOM = 200; + + +const MovieList = ({movies, apiUrl, parentPath, movieCount, sortExpression}) => { + + // Setup asynchronous component state that triggers re-render on change. + const [state, setState] = useState({ movies }); + + const listContainerId = `movieListContainer_${parentPath}`; + + // UseEffect with these arguments ( function, [] ) corresponds to componentDidMount in the old-school class-based react components. + useEffect( + ()=>{ + console.log("Initializing..."); + + nextOffset = movieCount; + + // Browser-specific functionality, so this is prevented from running on the SSR + if (typeof document === 'object' && typeof document.addEventListener === 'function' && typeof window !== 'undefined') { + initScrollListener(); + } + }, + [] + ); + + // Set up scroll listener, when the component is first mounted. + // Causes a trigger func function to be called when the bottom of the visible window is scrolled down to less + // than TRIGGER_OFFSET_PX_FROM_BOTTOM of the movie list element. + const initScrollListener = () => { + console.log("Init scroll listener"); + + var movieListElem = document.getElementById(listContainerId); + + // ACTUAL SCROLL LISTENER: + window.addEventListener("scroll", () => { + if (listenForScroll) { + + + var movieBounds = movieListElem.getBoundingClientRect(); + if (movieBounds.bottom < window.innerHeight + TRIGGER_OFFSET_PX_FROM_BOTTOM) { + console.log("!!! SCROLL TRIGGER !!!"); + + listenForScroll = false; + + makeRequest(); + + } + } + }); + }; + + // ------------------------------------------------------ + // Set up action methods, triggered by listener: + + // Makes a (guillotine) request for data with these search parameters and passes an anonymous callback function as + // handleDataFunc (used on the returned list of movie data). + const makeRequest = () => { + console.log("Requesting", movieCount, "movies, starting from index", nextOffset); + doGuillotineRequest({ + url: apiUrl, + + query: buildQueryListMovies(), + + variables: { + first: movieCount, + offset: nextOffset, + sort: sortExpression, + parentPathQuery: buildParentPathQuery(parentPath) + }, + + extractDataFunc: extractMovieArray, + + handleDataFunc: updateDOMWithNewMovies + }); + }; + + // When a movie data array is returned from the guillotine data request, this method is called. + const updateDOMWithNewMovies = (newMovieItems) => { + console.log("Received data:", newMovieItems); + if (newMovieItems.length > 0) { + console.log("Adding movies to state:", newMovieItems.map(movie => movie.title)); + + nextOffset += movieCount; + + // Use a function, not just a new direct object/array, for mutating state object/array instead of replacing it: + setState(oldState => ({ + movies: [ + ...oldState.movies, + ...newMovieItems + ] + })); + + console.log("Added new movies to state / DOM."); + + listenForScroll = true; + + } else { + setTimeout( + () => { listenForScroll = true; }, + 500 + ) + + } + }; + + // ------------------------------------------------------------------------------------ + // Actual rendering: + + return ( +
+ {state.movies + ? state.movies.map(movie => + + ) + : null + } +
+ ); +}; + +// MUST use this export line wrapping, because of the useState hook. +export default (props) => ; +---- +<1> `listenForScroll` is the scroll-listener's enabled-switch. +<2> Threshold value: if the distance between the bottom of the screen and the bottom of the movielist DOM container is less than this number of pixels, `makeRequest` should be triggered. +<3> We store a string to uniquely identify the movie-list container element in the DOM. +<4> In the component-initializing function (remember `useEffect`), we want to call `initScrollListener`. It's a one-time function that sets up a scroll listener that will last for the lifespan of the component. However, we only want this scroll listener setup to run in a browser! It's the only thing that makes sense, but more importantly, remember that _MovieList.jsx_ is server-side rendered from the controller - so this very same script will run in Nashorn in XP! ++ +NOTE: *Referring to most browser-specific functionality during server-side rendering will usually throw an error and of course break the rendering*. Checking for the global variables `document` or `window` is an easy way to prevent this. The exception is any link:https://www.npmjs.com/package/react4xp-runtime-nashornpolyfills[nashorn-polyfilled] functionality. +<5> During `initScrollListener`, we start by storing a handle to the movie-list container element in the DOM. +<6> The scroll event listener will be prevented from doing anything as long as `listenForScroll` is false. +<7> Here the distance between the bottom of the screen and the bottom of the movie-list container element is calculated. If that's smaller than the threshold `TRIGGER_OFFSET_PX_FROM_BOTTOM`, disable the listener and trigger `makeRequest`, which performs the same duties as before: request movie data from the guillotine API, and insert that into the state to trigger rendering... +<8> ...with one thing added: switch the scroll listener back on when data has been received and handled, OR after 500 ms after receiving empty data. +<9> Removing the click listener and adding the unique ID `listContainerId` to the container element. + +{zwsp} + + +And there we have it: *our infinite scroller*! + +Rebuild, refresh the preview of _MovieSite_, and instead of clicking, just scroll down - the page should auto-refresh to add new content until the very end of time or the end of your added movies, whichever comes first. + + +{zwsp} + +{zwsp} + +{zwsp} + + +== Other resources and tools + +TIP: This section is not a vital part of the rest of this or the next chapter. Feel free to skip it and miss out. + +To dive deeper into guillotine and graphQL, you can always check out the link:https://developer.enonic.com/templates/headless-cms[headless starter]. + +[[graphql_playground]] +=== GraphQL playground + +There is a handy tool in the headless starter, that we might as well add here too: the GraphQL Playground. + +The GraphQL Playground is basically an in-browser *GraphQL REPL interface*. It allows you to send queries and variables to the frontend API and see the results immediatly: experiment around, get to know link:https://graphql.org/learn/[GraphQL] and link:https://developer.enonic.com/docs/guillotine/2.x/api[guillotine], and build queries more effectively. + +To add this, first add the _graphql-playground_ library as a dependency in _build.gradle_: + +./build.gradle: +[source,groovy,options="nowrap"] +---- + +// (...) + +dependencies { + // (...) + + // Add this under dependencies: + include "com.enonic.lib:lib-graphql-playground:0.0.1" +} +---- + +Next, copy this code in at the end of _headless/guillotineApi.es6_: + +.headless/guillotineApi.es6: +[source,javascript,options="nowrap"] +---- + +// ---------------------------------- Graphql playground, at the same URL as the API ----------------- + +var graphqlPlaygroundLib = require('/lib/graphql-playground'); +var authLib = require('/lib/xp/auth'); + +// GraphQL playground +exports.get = function (req) { + if (req.webSocket) { + + return { + webSocket: { + subProtocols: ['graphql-ws'] + } + }; + } + + // Simple auth control for the playground + if (!authLib.hasRole('system.authenticated')) { + return { + status: 401, + body: { + "errors": [ {"errorType": "401", "message": "Unauthorized"} ] + } + }; + } + if (!(authLib.hasRole('system.admin') || authLib.hasRole('system.admin.login'))) { + return { + status: 403, + body: { + "errors": [ {"errorType": "403", "message": "Forbidden"} ] + } + }; + } + + var body = graphqlPlaygroundLib.render(); + return { + contentType: 'text/html; charset=utf-8', + body: body + }; +}; + +---- + +That's it. Remember that <<#expose_api, the controller mapping exposes _guillotineApi.es6_>> on the URL `/api/headless` below sites that use this app? We just added a GET response method there, so now if you... + +- rebuild your project, +- enter Content Studio again, +- select your movielist site, +- preview it, +- and then add `/api/headless` after the URL in the preview tab (e.g. `http://localhost:8080/admin/site/preview/default/draft/moviesite/api/headless`), + +...the playground should be rendered up and running and ready for you to explore: + +image:graphql-playground.png[title="The GraphQL playground", width=1024px] + + + +{zwsp} + +{zwsp} + +{zwsp} + +{zwsp} + +{zwsp} + +{zwsp} + + +--- + +The link:https://iconscout.com/icons/movie[movie icon] that marks the _movie_ content type in the screengrabs is by link:https://iconscout.com/contributors/phoenix-group[Phoenix Dungeon] on link:https://iconscout.com[Iconscout]. diff --git a/docs/imports-and-dependency-chunks.adoc b/docs/imports-and-dependency-chunks.adoc index d05550ba..dba68fd7 100644 --- a/docs/imports-and-dependency-chunks.adoc +++ b/docs/imports-and-dependency-chunks.adoc @@ -384,7 +384,7 @@ export default ({colors, selectedIndex, clickFunc}) => In this section we'll adjust some settings to make the code above work. -TIP: Some of this is covered in more detail under <> and <>. +TIP: If you need more details, some of this is covered in more depth under <> and <>. {zwsp} + @@ -416,6 +416,7 @@ Adding paths like e.g. `foo/bar/baz` or `../../hey/ho/lets/go` is perfectly fine {zwsp} + +[[webpack_rules]] === Adding webpack rules Next, we'll add some *custom webpack rules to handle the style imports*. First, we need to tell react4xp to use an extra webpack config file. Return to _react4xp.properties_ to add another property: diff --git a/docs/media/add_movies.png b/docs/media/add_movies.png new file mode 100644 index 00000000..6659afdd Binary files /dev/null and b/docs/media/add_movies.png differ diff --git a/docs/media/click-data.png b/docs/media/click-data.png new file mode 100644 index 00000000..e9d6296e Binary files /dev/null and b/docs/media/click-data.png differ diff --git a/docs/media/click-fill-dom.png b/docs/media/click-fill-dom.png new file mode 100644 index 00000000..7a09a9dc Binary files /dev/null and b/docs/media/click-fill-dom.png differ diff --git a/docs/media/edit_movie.png b/docs/media/edit_movie.png new file mode 100644 index 00000000..08be4b33 Binary files /dev/null and b/docs/media/edit_movie.png differ diff --git a/docs/media/graphql-playground.png b/docs/media/graphql-playground.png new file mode 100644 index 00000000..93cbce6c Binary files /dev/null and b/docs/media/graphql-playground.png differ diff --git a/docs/media/guillotine.jpg b/docs/media/guillotine.jpg new file mode 100644 index 00000000..961d63e9 Binary files /dev/null and b/docs/media/guillotine.jpg differ diff --git a/docs/media/movie-list-part-config.png b/docs/media/movie-list-part-config.png new file mode 100644 index 00000000..2f20cd70 Binary files /dev/null and b/docs/media/movie-list-part-config.png differ diff --git a/docs/media/webapp_applications.png b/docs/media/webapp_applications.png new file mode 100644 index 00000000..7fa10878 Binary files /dev/null and b/docs/media/webapp_applications.png differ diff --git a/docs/media/webapp_url.png b/docs/media/webapp_url.png new file mode 100644 index 00000000..ee05235b Binary files /dev/null and b/docs/media/webapp_url.png differ diff --git a/docs/menu.json b/docs/menu.json index 8427ae30..dd2274c7 100644 --- a/docs/menu.json +++ b/docs/menu.json @@ -28,6 +28,14 @@ "title": "7 - Imports and chunks", "document": "imports-and-dependency-chunks" }, + { + "title": "8 - Headless: guillotine", + "document": "guillotine" + }, + { + "title": "9 - Headless: webapp", + "document": "webapp" + }, { "title": "Entries", "document": "entries" diff --git a/docs/pages-parts-and-regions.adoc b/docs/pages-parts-and-regions.adoc index 1267d52c..107a2a04 100644 --- a/docs/pages-parts-and-regions.adoc +++ b/docs/pages-parts-and-regions.adoc @@ -70,7 +70,7 @@ Start with a completely generic content type: _site/content-types/my-content/my- .my-content.xml: [source,xml,options="nowrap"] ---- - + My Content Just some content base:structured diff --git a/docs/setup.adoc b/docs/setup.adoc index 238c1977..a60e1301 100644 --- a/docs/setup.adoc +++ b/docs/setup.adoc @@ -28,32 +28,35 @@ Remember to create a *new XP sandbox* when completing the project wizard. == Project structure If you're used to working with a typical XP project using https://developer.enonic.com/templates/webpack[webpack], the structure in your project root folder will now look familiar - -the biggest news is `react4xp.properties` and the folder `resources/react4xp/`. +the biggest news are _react4xp.properties_ and the folders _src/main/resources/react4xp/_ and _src/main/resources/headless_. -TIP: This is just an overview, no need to know it by heart to get started - we'll get back to all of these in more detail. For the most impatient, the takeaway is: *put your JSX source files somewhere below `react4xp/entries/` or `site/`*. +TIP: This is just an overview, no need to know it by heart to get started - we'll get back to all of these in more detail. For the most impatient, the takeaway is: *put your JSX source files somewhere below _react4xp/entries/_ or _site/_*. [source,files] ---- -build.gradle +build.gradle gradle.properties settings.gradle package.json -react4xp.properties +react4xp.properties src/ main/ resources/ - react4xp/ + react4xp/ entries/ shared/ + headless/ + guillotineApi.es6 + guillotineRequest.es6 ---- -<1> A set of gradle config files and `package.json`. These define the build process and project structure, and in particular, make sure that the *lib-react4xp library* and *react4xp npm package* are installed right. -<2> `react4xp.properties`: tune the most common properties of the react4xp project. -<3> The `react4xp/` folder is opt-in: can be ignored for now, and doesn't need to contain anything. But when fine-tuning the build later in the tutorial, we'll get back to why you might find it handy to have a separate place for some of your react components (in ultra-short: the `entries\` folder is for react components that can be used directly by react4xp, the `shared\` folder is for common components imported by, and shared between, the entries). - +<1> A set of gradle config files and _package.json_. These define the build process and project structure, and in particular, make sure that the *lib-react4xp library* and *react4xp npm package* are installed right. +<2> _react4xp.properties_: tune the most common properties of the react4xp project. +<3> The _react4xp/_ folder is opt-in: can be ignored for now, and doesn't need to contain anything. But when fine-tuning the build later in the tutorial, we'll get back to why you might find it handy to have a separate place for some of your react components (in ultra-short: the _entries/_ folder is for react components that can be used directly by react4xp, the _shared\_ folder is for common components imported by, and shared between, the entries). +<4> Under _headless/_, the starter includes two wrappers that make it easier to use the optional guillotine api for using react4xp in a <>. {zwsp} + diff --git a/docs/webapp.adoc b/docs/webapp.adoc new file mode 100644 index 00000000..707cf2e1 --- /dev/null +++ b/docs/webapp.adoc @@ -0,0 +1,625 @@ += Going Headless, part 2: standalone webapp +:toc: right +:imagesdir: media/ + +{zwsp} + +{zwsp} + + +[TIP] +==== +This chapter builds on the source code from <>, especially the entry and dependencies, and the assets that they're compiled into. + +If you've completed that lesson, nice. But that code is not in focus here and we won't look at it much - what we'll build in this chapter is _around_ the compiled assets from entries and dependencies, just referring to them. + +*What counts here are the general principles and usage patterns*. They should become pretty clear pretty soon. +==== + +{zwsp} + + +== Intro + +=== The story so far + +Until now in all the previous examples, the XP controllers have still been at the center of the rendering. + +Even in the <>, which demonstrated how to make the frontend react components render with data fetched from an API, the response from the initial request to the page still came from an XP controller. + +In that response there was a serverside-rendered initial HTML rendering of the entry, references to dependency and entry assets (in the right order), and a client-side wrapper (fetched from a referred XP service) that handles the react rendering/hydration trigger in the browser. All of this was automatically generated and included from the `React4xp.render` call in the XP controller. + +{zwsp} + + +=== Going fully headless + +But in general, when the react4xp buildtime compiles the react component source files (etc) into assets, what happens is just a *regular webpack* process that generates *regular JS (etc) assets* that can be run in the browser in pretty much any regular way - independent of the react4xp runtime. + +*In other words, you can serve the react4xp-compiled assets from anywhere and use them however you want.* + +Now that we've seen how to set up an API that serves XP content data, and that using that data to render something with react in the browser boils down to passing props and handling a component state, this opens up for *headless approaches!* + +{zwsp} + +{zwsp} + +{zwsp} + + +[[nutshell]] +== Standalone react4xp in a nutshell + +The takeaway of this chapter is this: react4xp components (entries and dependencies) can be used without any XP controller or `React4xp.render` call. To do that, the base HTML (the response of a first page request, or some script loaded by it) *must do these tasks*: + +<1> Load React and ReactDOM from somewhere. For example a CDN, +<2> Load all the <> that the entries need (before loading the entry assets!), +<3> Load all <>. This will make each entry available in the browser's JS namespace as a react-component-creating function, at `React4xp[jsxPath].default`, for each entry's <>. +<4> Construct a props object for each entry that uses props. This is where we'll contact the <> in this lesson, for fetching the data from XP. +<5> Use the props with each entry's component-creating function to create a react-renderable component: ++ +[source,javascript,options="nowrap"] +---- +const component = React4xp[jsxPath].default(props); +---- +<6> Finally, render the component into your DOM: ++ +[source,javascript,options="nowrap"] +---- +ReactDOM.render(component, document.getElementById("my-target-container-id")); +---- + +If you've used react before, this is the same flow. + +{zwsp} + + +NOTE: *This approach does NOT include server-side react rendering!* At least not out of the box. But since the react4xp build processes are pretty regular webpack, using regular react, it should be perfectly possible to tweak things and engineer your own solutions. If you go down that path, using Node.js instead of Nashorn for the SSR engine might be easier - Nashorn needs more polyfilling. + + +{zwsp} + +{zwsp} + +{zwsp} + + +== Lesson overview + +We'll take a look at *two variations* of how to use react4xp-compiled components without rendering them from XP controllers. Both of them do <<#nutshell, the same steps above>>, in slightly different ways: + +{zwsp} + + +<1> *Completely standalone:* this first variation is the manual, hardcoded, vanilla-js-and-react webapp approach. The HTML and script do everything explicitly: asset URLs and initial values are *handled and organized manually in the HTML itself*, and the script at the end fetches data from guillotine, organizes them into props and makes a *regular `ReactDOM.render` call*. In this approach, XP's role is mainly to serve content data through the guillotine API. Pretty independent but there are no helpers; so getting things right is up to you. ++ +{zwsp} + +<2> *Webapp with XP helpers:* this second variation is "slightly standalone": we use XP to wrap a little boilerplate for convenience: *XP and thymeleaf provides some initial values*. The script at the end is still loaded and used to fetch data and create props, but instead of having the HTML load all the entry and dependency assets and call `React4xp.render`, the react4xp client wrapper is loaded in order to use a *react4xp helper function:* `.renderWithDependencies`. This is an all-in-one rendering trigger that takes one or more <> with props and target container ID's, and uses *XP services* for auto-tracking and loading all the assets needed (including dependency chunks) before rendering them. + +{zwsp} + + +Keep in mind that except for the XP services in the second variation, *no running XP is strictly necessary for this to work*. The data-serving endpoint could be any API (e.g. REST) instead of guillotine, and the initial HTML and JS/CSS assets are static and could be served from anywhere. Use whatever approach suits your project. + +But we'll use XP anyway in this chapter: we already have it up and running from the previous chapters. So we'll use link:https://developer.enonic.com/guides/my-first-webapp[the XP webapp functionality] (see link:https://developer.enonic.com/docs/xp/stable/runtime/engines/webapp-engine[here] for more documentation) to serve the initial HTML, and the link:https://developer.enonic.com/docs/xp/stable/runtime/engines/asset-service[regular XP asset functionality] for serving the assets for the entries and dependencies. + +{zwsp} + + +=== Source files + +.Files involved (src/main/resources/...) - in addition to <>: +[source,files] +---- +webapp/ + webapp.es6 + webapp.html + +assets/webapp/ + script.es6 +---- + +XP uses _webapp.es6_ and _webapp.html_ to generate an initial HTML that directly makes the browser run most of <<#nutshell, the steps above>>, fetching assets and setting up initial values, and then calling the final _script.es6_ asset, which handles the rest. + +{zwsp} + + + + + + + + + + + + + + + + + + + +{zwsp} + +{zwsp} + +{zwsp} + + + +== 1. Completely standalone + +In this first of the two approaches, we'll minimize the use of runtime XP: all values and asset URLs are hardcoded. XP is used to serve the initial HTML and the assets, as well as providing a guillotine endpoint where the browser can fetch data, but this functionality can easily be replaced with any other file- and data-serving solution and still work just fine. + +[[html1]] +=== HTML base + +The webapp begins with some basic HTML, setting it all up in the browser. + +.webapp.html: +[source,html,options="nowrap"] +---- + + + + + Completely standalone + + + + + + + + + + + + + + + + + + +

Top 3 movies to put in a list

+

#4 will blow your mind!

+ + +
+
Loading movies...
+
+ + + + + + + + + +---- +<1> We start by running React and ReactDOM from a CDN. +<2> Next, we fetch 3 dependency chunks that the _MovieList_ entry needs: _shared.{hash}.js_, _shared.{hash}.css_ and _MovieList.css_. ++ +[NOTE] +==== +As before, the asset path URLs depend on your setup - and this applies to both the *content-dependent {hash}* in the filename and the *asset path* itself - whatever the root path to your asset source is. For example, on my local machine running XP, the `(...my.asset.url)` part of the path looks like this: + +`/_/asset/com.enonic.app.react4xp:1604314030` + +...and the part `:1604314030` can even be skipped (at the expense of caching). If you've run through the previous chapter, you can for example copy the corresponding asset-URLs from the produced page source HTML in the preview. +==== +<3> The _MovieList_ entry asset. Loading and running this will expose the entry in the global JS namescape as a function that creates a react component: `React4xp['MovieList'].default`. +<4> The target container for the react app. The not-really-a-spinner (_"Loading movies..."_) will be replaced when the actual _MovieList_ is rendered into the container. +<5> Variables used by our particular script later, just wrapped in a `MOVIE_LIST_PARAMS` object to encapsulate them in the global namespace. These are the same values as in in the previous chapter, and the script at the end will use these in a `props` object, to create the renderable react app from the _MovieList_ entry. ++ +Also note that we just hardcoded the values of `parentPath`, `apiUrl` and `movieType` here - they may be different in your setup. As <>: `parentPath` is the content path of the site item under which the movie items will be found, `apiUrl` is the full path to `/api/headless` _below that site_ (recall that the URL to the guillotine API depends on the URL of a site item like this, because of the way we set the API up with a controller mapping <>), and `movietype` is the full, appname-dependent content type of the movie items to look for. +<6> Finally, loading the compiled script from _script.es6_ <<#script, below>>. + +{zwsp} + + +A *webapp controller* is needed for XP to serve this HTML, and it's about as minimal as an XP controller can be: + +.webapp.es6: +[source,javascript,options="nowrap"] +---- +import thymeleaf from '/lib/thymeleaf'; +const view = resolve('webapp.html'); + +exports.get = () => ({ + contentType: 'text/html', + body: thymeleaf.render(view, {}) +}); +---- + +{zwsp} + + +[[script]] +=== The script + +Finally, the script that's called at the end of the HTML. + +If you've been through the lesson in the <>, you might recognize that these functions are mostly the same code as was used in that chapter, just copied into one asset (if you haven't, just see that chapter for reference). + +The main function is `requestAndRenderMovies`. It gets its input values from the `MOVIE_LIST_PARAMS` object we defined in the global namespace in the HTML earlier, then uses these to request data about 3 (`movieCount`) movies (`movieType`) under the _movielist_ site (`parentPath`), from the guillotine API. Just like in the previous chapter, the guillotine query string for fetching movies is built with a function, `buildQueryListMovies`. The returned data is parsed into a JSON array of movie objects (`extractToMovieArray`) and passed to the `renderMovie` function, where it's used in a `props` object alongside other values from `MOVIE_LIST_PARAMS`. Along with the `props`, the _MovieList_ entry (`React4xp['MovieList]`) is used to create a renderable react component that is rendered into the target `movieListContainer` element in the DOM with `ReactDOM.render`, now as a top-level react app. + + +.script.es6: +[source,javascript,options="nowrap"] +---- +const buildQueryListMovies = (movieType, parentPath) => { + const matched = movieType.match(/(\w+(\.\w+)*):(\w+)/i); // verifies content type names like "com.enonic.app.react4xp:movie" and matches up groups before and after the colon + if (!matched) { + throw Error(`movieType '${movieType}' is not a valid format. Expecting :, for example: 'com.enonic.app.react4xp:movie' etc`); + } + const appNameUnderscored = matched[1].replace(/\./g, '_'); // e.g. "com.enonic.app.react4xp" --> "com_enonic_app_react4xp + const ctyCapitalized = matched[3][0].toUpperCase() + matched[3].substr(1); // e.g. "movie" --> "Movie" + + return ` +query($first:Int!, $offset:Int!, $sort:String!) { + guillotine { + query(contentTypes: ["${movieType}"], query: "_parentPath = '/content${parentPath}'", first: $first, offset: $offset, sort: $sort) { + ... on ${appNameUnderscored}_${ctyCapitalized} { + _id + displayName + data { + year + description + actor + image { + ... on media_Image { + imageUrl(type: absolute, scale: "width(300)") + } + } + } + } + } + } +}`; +}; + +// Not using util-lib to ensure usability on frontend +const forceArray = maybeArray => Array.isArray(maybeArray) + ? maybeArray + : maybeArray + ? [maybeArray] + : []; + + +const extractMovieArray = responseData => responseData.data.guillotine.query + .filter( movieItem => movieItem && typeof movieItem === 'object' && Object.keys(movieItem).indexOf('data') !== -1) + .map( + movieItem => ({ + id: movieItem._id, + title: movieItem.displayName.trim(), + imageUrl: movieItem.data.image.imageUrl, + year: movieItem.data.year, + description: movieItem.data.description, + actors: forceArray(movieItem.data.actor) + .map( actor => (actor || '').trim()) + .filter(actor => !!actor) + }) + ); + + +// --------------------------------------------------------- + +// Makes a (guillotine) request for data with these search parameters and passes updateDOMWithNewMovies as the callback +// function to use on the returned list of movie data +const requestAndRenderMovies = () => { + fetch( + MOVIE_LIST_PARAMS.apiUrl, + { + method: "POST", + body: JSON.stringify({ + query: buildQueryListMovies( + MOVIE_LIST_PARAMS.movieType, + MOVIE_LIST_PARAMS.parentPath + ), + variables: { + first: MOVIE_LIST_PARAMS.movieCount, + offset: 0, + sort: MOVIE_LIST_PARAMS.sortExpression + }} + ), + } + ) + .then(response => { + if (!(response.status < 300)) { + throw Error(`Guillotine API response:\n + \n${response.status} - ${response.statusText}.\n + \nAPI url: ${response.url}\n + \nInspect the request and/or the server log.`); + } + return response; + }) + + .then(response => response.json()) + .then(extractMovieArray) + .then(renderMovies) + .catch( error => {console.error(error);}) +}; + + + + +const renderMovies = (movies) => { + console.log("Rendering initial movies:", movies); + + // When compiled, all react4xp entries are exported as functions, + // as "default" under the entryName (jsxPath), inside the global object React4xp: + const componentFunc = React4xp['MovieList'].default; + + // Run the componentFunc with the props as argument, to build a renderable react component: + const props = { + movies: movies, + apiUrl: MOVIE_LIST_PARAMS.apiUrl, + parentPath: MOVIE_LIST_PARAMS.parentPath, + movieCount: MOVIE_LIST_PARAMS.movieCount, + movieType: MOVIE_LIST_PARAMS.movieType, + sortExpression: MOVIE_LIST_PARAMS.sortExpression + }; + const component = componentFunc(props); + + // Get the DOM element where the movie list should be rendered: + const targetElement = document.getElementById("movieListContainer"); + + // Straight call to ReactDOM (loaded from CDN): + ReactDOM.render(component, targetElement); +}; + + +// Finally, calling the entry function and running it all: +requestAndRenderMovies(); +---- + +{zwsp} + + +[[output1]] +=== Output + +Assuming you've been through the <>, you can now rebuild the project. But instead of opening Content Studio, open the XP main menu in the top right corner, choose _Applications_, and in the Applications viewer, select your app: + +image:webapp_applications.png[title="Select your app in the Applications viewer", width=1024px] + +{zwsp} + +At the bottom of the app info panel, you'll see a URL where you can preview the webapp we just built: + +image:webapp_url.png[title="URL to preview the webapp.", width=1024px] + +{zwsp} + +Clicking this link should now show you the working webapp - listing 3 initial movies and filling in more as you scroll down, just like in the preview at the end of the previous chapter. + + + +{zwsp} + +{zwsp} + +{zwsp} + + + +== 2. Webapp with XP helpers + +This is all nice and well, but a cumbersome part is that it requires you to supply values and asset URLs yourself, or ways to figure them out. Hashes in file names is a neat way of content-based cache busting, but keeping track of the resulting file names can be a chore. Even if that's not an issue, it could be handy to have a way to just supply the name (jsxPath) of the entry (or entries) you want to render, and let the system itself figure out what dependency chunk(s) are needed to load alongside the entry asset(s). Not to mention prevent them from being downloaded twice. + +If you have XP running but still want to make things work headlessly and outside of Content Studio, react4xp provides a couple of helpers for this. The client wrapper comes with the function `.renderWithDependencies`, which uses an XP service to track the sum set of dependency chunks required to render a set of jsxPaths, loads them and the entry assets, and renders them. + +=== HTML base + +The HTML base is very similar to the one in <<#html1, the previous example>>, only this time since we're using XP anyway, it's a more traditional link:https://developer.enonic.com/docs/thymeleaf-library/master[thymeleaf template]. This means we can get rid of most of the hardcoded stuff in the HTML. + +Just like before, we get react/react-dom from a CDN, provide a pinch of styling and a ready container to render into (with a "spinner". Whatever). But the differences are these: + +.webapp.html: +[source,html,options="nowrap"] +---- + + + + + All headless + + + + + + + + + + + +

Top 3 movies to put in a list

+

#4 will blow your mind!

+ +
+
Loading movies...
+
+ + + + + + + + +---- +<1> This is where we <<#html1, previously>> put hardcoded URLs to each specific dependency chunk and entry asset we want to use. Here, we only load the react4xp client-wrapper, and make it available in the browser’s namespace as `React4xp.CLIENT` (for more details, see link:https://www.npmjs.com/package/react4xp-runtime-client[the client wrapper docs on NPM]). +<2> As before, we set a few initial values for the final script to use. Two things are different here, though: first, we let the XP controller (right below) supply the appname-dependent content type and the content path to the site with the movies below it. And second: `serviceUrlRoot`. This value is the URL root of the XP services, and lets the script know where to look for the service that tracks the entries' assets and dependencies. +<3> The final script asset (<<#script2, below>>), also loaded by using the `portal.assetUrl` function with thymeleaf instead of hardcoded. + +{zwsp} + + +The *webapp controller* needs to provide that extra little info to the values in `MOVIE_LIST_PARAMS`, in the thymeleaf `model` now: + +.webapp.es6: +[source,javascript,options="nowrap"] +---- +import thymeleaf from '/lib/thymeleaf'; +const view = resolve('webapp.html'); + +exports.get = req => { + const model = { + sitePath: "/moviesite", + movieType: `${app.name}:movie` + }; + + return { + contentType: 'text/html', + body: thymeleaf.render(view, model) + }; +}; +---- + +{zwsp} + + +[[script2]] +=== The script asset + +The script asset is almost identical to before. All that's changed is in `renderMovies` at the end: + +.script.es6: +[source,javascript,options="nowrap"] +---- +const buildQueryListMovies = (movieType, parentPath) => { + const matched = movieType.match(/(\w+(\.\w+)*):(\w+)/i); // verifies content type names like "com.enonic.app.react4xp:movie" and matches up groups before and after the colon + if (!matched) { + throw Error(`movieType '${movieType}' is not a valid format. Expecting :, for example: 'com.enonic.app.react4xp:movie' etc`); + } + const appNameUnderscored = matched[1].replace(/\./g, '_'); // e.g. "com.enonic.app.react4xp" --> "com_enonic_app_react4xp + const ctyCapitalized = matched[3][0].toUpperCase() + matched[3].substr(1); // e.g. "movie" --> "Movie" + + return ` +query($first:Int!, $offset:Int!, $sort:String!) { + guillotine { + query(contentTypes: ["${movieType}"], query: "_parentPath = '/content${parentPath}'", first: $first, offset: $offset, sort: $sort) { + ... on ${appNameUnderscored}_${ctyCapitalized} { + _id + displayName + data { + year + description + actor + image { + ... on media_Image { + imageUrl(type: absolute, scale: "width(300)") + } + } + } + } + } + } +}`; +}; + +// Not using util-lib to ensure usability on frontend +const forceArray = maybeArray => Array.isArray(maybeArray) + ? maybeArray + : maybeArray + ? [maybeArray] + : []; + + +const extractMovieArray = responseData => responseData.data.guillotine.query + .filter( movieItem => movieItem && typeof movieItem === 'object' && Object.keys(movieItem).indexOf('data') !== -1) + .map( + movieItem => ({ + id: movieItem._id, + title: movieItem.displayName.trim(), + imageUrl: movieItem.data.image.imageUrl, + year: movieItem.data.year, + description: movieItem.data.description, + actors: forceArray(movieItem.data.actor) + .map( actor => (actor || '').trim()) + .filter(actor => !!actor) + }) + ); + + +// --------------------------------------------------------- + +// Makes a (guillotine) request for data with these search parameters and passes updateDOMWithNewMovies as the callback +// function to use on the returned list of movie data +const requestAndRenderMovies = () => { + fetch( + MOVIE_LIST_PARAMS.apiUrl, + { + method: "POST", + body: JSON.stringify({ + query: buildQueryListMovies( + MOVIE_LIST_PARAMS.movieType, + MOVIE_LIST_PARAMS.parentPath + ), + variables: { + first: MOVIE_LIST_PARAMS.movieCount, + offset: 0, + sort: MOVIE_LIST_PARAMS.sortExpression + }} + ), + } + ) + .then(response => { + if (!(response.status < 300)) { + throw Error(`Guillotine API response:\n + \n${response.status} - ${response.statusText}.\n + \nAPI url: ${response.url}\n + \nInspect the request and/or the server log.`); + } + return response; + }) + + .then(response => response.json()) + .then(extractMovieArray) + .then(renderMovies) + .catch( error => {console.error(error);}) +}; + + + + +const renderMovies = (movies) => { + console.log("Rendering movies:", movies); + + const props = { + movies: movies, + apiUrl: MOVIE_LIST_PARAMS.apiUrl, + parentPath: MOVIE_LIST_PARAMS.parentPath, + movieCount: MOVIE_LIST_PARAMS.movieCount, + movieType: MOVIE_LIST_PARAMS.movieType, + sortExpression: MOVIE_LIST_PARAMS.sortExpression + }; + + + React4xp.CLIENT.renderWithDependencies( + { + 'MovieList': { + targetId: 'movieListContainer', + props: props + } + }, + null, + MOVIE_LIST_PARAMS.serviceUrlRoot + ); +}; + + +// Finally, calling the entry function and running it all: +requestAndRenderMovies(); +---- +<1> No need to create the react components explicitly. As soon as the `props` are created, everything from there is handled by the `React4xp.CLIENT.renderWithDependencies` wrapper function (see link:https://www.npmjs.com/package/react4xp-runtime-client#renderwithdependencies[the react4xp client docs] for usage and details). ++ +NOTE: This is where the `serviceUrlRoot` value is used - .renderWithDependencies` will not work without one! + +{zwsp} + + +=== Output + +The output when you preview it should be the same as <<#output1, the previous example>>.