FEATURE: Support for publishing topics as pages (#9364)

FEATURE: Support for publishing topics as pages (#9364)

If the feature is enabled, staff members can construct a URL and publish a topic for others to browse without the regular Discourse chrome.

This is useful if you want to use Discourse like a CMS and publish topics as articles, which can then be embedded into other systems.

diff --git a/app/assets/javascripts/discourse/adapters/published-page.js b/app/assets/javascripts/discourse/adapters/published-page.js
new file mode 100644
index 0000000..1922673
--- /dev/null
+++ b/app/assets/javascripts/discourse/adapters/published-page.js
@@ -0,0 +1,9 @@
+import RestAdapter from "discourse/adapters/rest";
+
+export default RestAdapter.extend({
+  jsonMode: true,
+
+  pathFor(store, type, id) {
+    return `/pub/by-topic/${id}`;
+  }
+});
diff --git a/app/assets/javascripts/discourse/components/text-field.js b/app/assets/javascripts/discourse/components/text-field.js
index bb57f25..3477520 100644
--- a/app/assets/javascripts/discourse/components/text-field.js
+++ b/app/assets/javascripts/discourse/components/text-field.js
@@ -71,6 +71,27 @@ export default TextField.extend({
     }
   },
 
+  didReceiveAttrs() {
+    this._super(...arguments);
+    this._prevValue = this.value;
+  },
+
+  didUpdateAttrs() {
+    this._super(...arguments);
+    if (this._prevValue !== this.value) {
+      if (this.onChangeImmediate) {
+        next(() => this.onChangeImmediate(this.value));
+      }
+      if (this.onChange) {
+        debounce(this, this._debouncedChange, DEBOUNCE_MS);
+      }
+    }
+  },
+
+  _debouncedChange() {
+    next(() => this.onChange(this.value));
+  },
+
   @discourseComputed("placeholderKey")
   placeholder: {
     get() {
diff --git a/app/assets/javascripts/discourse/controllers/publish-page.js b/app/assets/javascripts/discourse/controllers/publish-page.js
new file mode 100644
index 0000000..37462fc
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/publish-page.js
@@ -0,0 +1,121 @@
+import Controller from "@ember/controller";
+import ModalFunctionality from "discourse/mixins/modal-functionality";
+import { computed, action } from "@ember/object";
+import { equal, not } from "@ember/object/computed";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+
+const States = {
+  initializing: "initializing",
+  checking: "checking",
+  valid: "valid",
+  invalid: "invalid",
+  saving: "saving",
+  new: "new",
+  existing: "existing",
+  unpublishing: "unpublishing",
+  unpublished: "unpublished"
+};
+
+const StateHelpers = {};
+Object.keys(States).forEach(name => {
+  StateHelpers[name] = equal("state", name);
+});
+
+export default Controller.extend(ModalFunctionality, StateHelpers, {
+  state: null,
+  reason: null,
+  publishedPage: null,
+  disabled: not("valid"),
+  publishedPage: null,
+
+  showUrl: computed("state", function() {
+    return (
+      this.state === States.valid ||
+      this.state === States.saving ||
+      this.state === States.existing
+    );
+  }),
+  showUnpublish: computed("state", function() {
+    return this.state === States.existing || this.state === States.unpublishing;
+  }),
+
+  onShow() {
+    this.set("state", States.initializing);
+
+    this.store
+      .find("published_page", this.model.id)
+      .then(page => {
+        this.setProperties({ state: States.existing, publishedPage: page });
+      })
+      .catch(this.startNew);
+  },
+
+  @action
+  startCheckSlug() {
+    if (this.state === States.existing) {
+      return;
+    }
+
+    this.set("state", States.checking);
+  },
+
+  @action
+  checkSlug() {
+    if (this.state === States.existing) {
+      return;
+    }
+    return ajax("/pub/check-slug", {
+      data: { slug: this.publishedPage.slug }
+    }).then(result => {
+      if (result.valid_slug) {
+        this.set("state", States.valid);
+      } else {
+        this.setProperties({ state: States.invalid, reason: result.reason });
+      }
+    });
+  },
+
+  @action
+  unpublish() {
+    this.set("state", States.unpublishing);
+    return this.publishedPage
+      .destroyRecord()
+      .then(() => {
+        this.set("state", States.unpublished);
+        this.model.set("publishedPage", null);
+      })
+      .catch(result => {
+        this.set("state", States.existing);
+        popupAjaxError(result);
+      });
+  },
+
+  @action
+  publish() {
+    this.set("state", States.saving);
+
+    return this.publishedPage
+      .update({ slug: this.publishedPage.slug })
+      .then(() => {
+        this.set("state", States.existing);
+        this.model.set("publishedPage", this.publishedPage);
+      })
+      .catch(errResult => {
+        popupAjaxError(errResult);
+        this.set("state", States.existing);
+      });
+  },
+
+  @action
+  startNew() {
+    this.setProperties({
+      state: States.new,
+      publishedPage: this.store.createRecord("published_page", {
+        id: this.model.id,
+        slug: this.model.slug
+      })
+    });
+    this.checkSlug();
+  }
+});
diff --git a/app/assets/javascripts/discourse/lib/transform-post.js b/app/assets/javascripts/discourse/lib/transform-post.js
index 585bb02..986dc46 100644
--- a/app/assets/javascripts/discourse/lib/transform-post.js
+++ b/app/assets/javascripts/discourse/lib/transform-post.js
@@ -76,7 +76,8 @@ export function transformBasicPost(post) {
     replyCount: post.reply_count,
     locked: post.locked,
     userCustomFields: post.user_custom_fields,
-    readCount: post.readers_count
+    readCount: post.readers_count,
+    canPublishPage: false
   };
 
   _additionalAttributes.forEach(a => (postAtts[a] = post[a]));
@@ -118,6 +119,8 @@ export default function transformPost(
     currentUser && (currentUser.id === post.user_id || currentUser.staff);
   postAtts.canReplyAsNewTopic = details.can_reply_as_new_topic;
   postAtts.canReviewTopic = !!details.can_review_topic;
+  postAtts.canPublishPage =
+    !!details.can_publish_page && post.post_number === 1;
   postAtts.isWarning = topic.is_warning;
   postAtts.links = post.get("internalLinks");
   postAtts.replyDirectlyBelow =
diff --git a/app/assets/javascripts/discourse/models/published-page.js b/app/assets/javascripts/discourse/models/published-page.js
new file mode 100644
index 0000000..7f0c12d
--- /dev/null
+++ b/app/assets/javascripts/discourse/models/published-page.js
@@ -0,0 +1,8 @@
+import RestModel from "discourse/models/rest";
+import { computed } from "@ember/object";
+
+export default RestModel.extend({
+  url: computed("slug", function() {
+    return `${Discourse.BaseUrl}/pub/${this.slug}`;
+  })
+});
diff --git a/app/assets/javascripts/discourse/models/topic.js b/app/assets/javascripts/discourse/models/topic.js
index 045f311..41734e6 100644
--- a/app/assets/javascripts/discourse/models/topic.js
+++ b/app/assets/javascripts/discourse/models/topic.js
@@ -545,6 +545,13 @@ const Topic = RestModel.extend({
       this.details.updateFromJson(json.details);
 
       keys.removeObjects(["details", "post_stream"]);
+
+      if (json.published_page) {
+        this.set(
+          "publishedPage",
+          this.store.createRecord("published-page", json.published_page)
+        );
+      }
     }
     keys.forEach(key => this.set(key, json[key]));
   },
diff --git a/app/assets/javascripts/discourse/routes/topic.js b/app/assets/javascripts/discourse/routes/topic.js
index 33e6041..5aebefe 100644
--- a/app/assets/javascripts/discourse/routes/topic.js
+++ b/app/assets/javascripts/discourse/routes/topic.js
@@ -89,6 +89,14 @@ const TopicRoute = DiscourseRoute.extend({
       controller.setProperties({ flagTopic: true });
     },
 
+    showPagePublish() {
+      const model = this.modelFor("topic");
+      showModal("publish-page", {
+        model,
+        title: "topic.publish_page.title"
+      });
+    },
+
     showTopicStatusUpdate() {
       const model = this.modelFor("topic");
 
diff --git a/app/assets/javascripts/discourse/templates/modal/publish-page.hbs b/app/assets/javascripts/discourse/templates/modal/publish-page.hbs
new file mode 100644
index 0000000..027f3ee
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/modal/publish-page.hbs
@@ -0,0 +1,62 @@
+{{#d-modal-body}}
+  {{#if unpublished}}
+    <p>{{i18n "topic.publish_page.unpublished"}}</p>

[... diff too long, it was truncated ...]

GitHub sha: e1f8014a

This commit appears in #9364 which was merged by eviltrout.

I am really excited about this one, thanks for it!

1 Like