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.
This commit is contained in:
Robin Ward 2020-04-08 12:52:36 -04:00 committed by GitHub
parent b64b590cfb
commit e1f8014acd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 883 additions and 7 deletions

View File

@ -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}`;
}
});

View File

@ -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() {

View File

@ -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();
}
});

View File

@ -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 =

View File

@ -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}`;
})
});

View File

@ -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]));
},

View File

@ -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");

View File

@ -0,0 +1,62 @@
{{#d-modal-body}}
{{#if unpublished}}
<p>{{i18n "topic.publish_page.unpublished"}}</p>
{{else}}
{{#conditional-loading-spinner condition=initializing}}
<p class="publish-description">{{i18n "topic.publish_page.description"}}</p>
<form>
<label>{{i18n "topic.publish_page.slug"}}</label>
{{text-field value=publishedPage.slug onChange=(action "checkSlug") onChangeImmediate=(action "startCheckSlug") disabled=existing class="publish-slug"}}
</form>
<div class="publish-url">
{{conditional-loading-spinner condition=checking}}
{{#if existing}}
<div class='current-url'>
{{i18n "topic.publish_page.publish_url"}}
<div>
<a href={{publishedPage.url}} target="_blank" rel="noopener">{{publishedPage.url}}</a>
</div>
</div>
{{else}}
{{#if showUrl}}
<div class="valid-slug">
{{i18n "topic.publish_page.preview_url"}}
<div class='example-url'>{{publishedPage.url}}</div>
</div>
{{/if}}
{{#if invalid}}
{{i18n "topic.publish_page.invalid_slug"}} <span class="invalid-slug">{{reason}}.</span>
{{/if}}
{{/if}}
</div>
{{/conditional-loading-spinner}}
{{/if}}
{{/d-modal-body}}
<div class="modal-footer">
{{#if showUnpublish}}
{{d-button icon="times" label="close" action=(action "closeModal")}}
{{d-button
label="topic.publish_page.unpublish"
icon="trash"
class="btn-danger"
isLoading=unpublishing
action=(action "unpublish") }}
{{else if unpublished}}
{{d-button label="topic.publish_page.publishing_settings" action=(action "startNew")}}
{{else}}
{{d-button
label="topic.publish_page.publish"
class="btn-primary publish-page"
icon="file"
disabled=disabled
isLoading=saving
action=(action "publish") }}
{{/if}}
</div>

View File

@ -85,6 +85,25 @@
{{topic-category topic=model class="topic-category"}}
{{/if}}
{{/topic-title}}
{{#if model.publishedPage}}
<div class='published-page'>
<div class="details">
{{i18n "topic.publish_page.topic_published"}}
<div>
<a href={{model.publishedPage.url}} target="_blank" rel="noopener">{{model.publishedPage.url}}</a>
</div>
</div>
<div class="controls">
{{d-button
icon="file"
label="topic.publish_page.publishing_settings"
action=(route-action "showPagePublish")
}}
</div>
</div>
{{/if}}
{{/if}}
<div class="container posts">
@ -230,7 +249,8 @@
selectBelow=(action "selectBelow")
fillGapBefore=(action "fillGapBefore")
fillGapAfter=(action "fillGapAfter")
showInvite=(route-action "showInvite")}}
showInvite=(route-action "showInvite")
showPagePublish=(route-action "showPagePublish")}}
{{/unless}}
{{conditional-loading-spinner condition=model.postStream.loadingBelow}}

View File

@ -120,6 +120,15 @@ export function buildManageButtons(attrs, currentUser, siteSettings) {
}
}
if (attrs.canPublishPage) {
contents.push({
icon: "file",
label: "post.controls.publish_page",
action: "showPagePublish",
className: "btn-default publish-page"
});
}
if (attrs.canManage) {
contents.push({
icon: "cog",

View File

@ -684,6 +684,27 @@
}
}
.publish-page-modal .modal-body {
p.publish-description {
margin-top: 0;
}
input.publish-slug {
width: 100%;
}
.publish-url {
margin-bottom: 1em;
.example-url,
.invalid-slug {
font-weight: bold;
}
}
.publish-slug:disabled {
cursor: not-allowed;
}
}
.modal:not(.has-tabs) {
.modal-tab {
position: absolute;

View File

@ -295,3 +295,14 @@ a.topic-featured-link {
}
}
}
.published-page {
display: flex;
justify-content: space-between;
padding-bottom: 1em;
max-width: calc(
#{$topic-body-width} + #{$topic-avatar-width} + #{$topic-body-width-padding *
2}
);
align-items: center;
}

View File

@ -0,0 +1,32 @@
@import "common";
body {
background-color: $secondary;
color: $primary;
}
.published-page {
margin: 2em auto;
max-width: 800px;
h1 {
color: $header_primary;
}
.published-page-author {
margin-top: 1em;
margin-bottom: 2em;
display: flex;
.avatar {
margin-right: 1em;
}
.topic-created-at {
color: $primary-medium;
}
}
.published-page-body {
font-size: 1.25em;
}
}

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
class PublishedPagesController < ApplicationController
skip_before_action :preload_json
skip_before_action :check_xhr, :verify_authenticity_token, only: [:show]
before_action :ensure_publish_enabled
def show
params.require(:slug)
pp = PublishedPage.find_by(slug: params[:slug])
raise Discourse::NotFound unless pp
guardian.ensure_can_see!(pp.topic)
@topic = pp.topic
@canonical_url = @topic.url
render layout: 'publish'
end
def details
pp = PublishedPage.find_by(topic: fetch_topic)
raise Discourse::NotFound if pp.blank?
render_serialized(pp, PublishedPageSerializer)
end
def upsert
result, pp = PublishedPage.publish!(current_user, fetch_topic, params[:published_page][:slug].strip)
json_result(pp, serializer: PublishedPageSerializer) { result }
end
def destroy
PublishedPage.unpublish!(current_user, fetch_topic)
render json: success_json
end
def check_slug
pp = PublishedPage.new(topic: Topic.new, slug: params[:slug].strip)
if pp.valid?
render json: { valid_slug: true }
else
render json: { valid_slug: false, reason: pp.errors.full_messages.first }
end
end
private
def fetch_topic
topic = Topic.find_by(id: params[:topic_id])
guardian.ensure_can_publish_page!(topic)
topic
end
def ensure_publish_enabled
raise Discourse::NotFound unless SiteSetting.enable_page_publishing?
end
end

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
class PublishedPage < ActiveRecord::Base
belongs_to :topic
validates_presence_of :slug
validates_uniqueness_of :slug, :topic_id
validate :slug_format
def slug_format
if slug !~ /^[a-zA-Z\-\_0-9]+$/
errors.add(:slug, I18n.t("publish_page.slug_errors.invalid"))
elsif ["check-slug", "by-topic"].include?(slug)
errors.add(:slug, I18n.t("publish_page.slug_errors.unavailable"))
end
end
def path
"/pub/#{slug}"
end
def url
"#{Discourse.base_url}#{path}"
end
def self.publish!(publisher, topic, slug)
transaction do
pp = find_or_initialize_by(topic: topic)
pp.slug = slug.strip
if pp.save
StaffActionLogger.new(publisher).log_published_page(topic.id, slug)
return [true, pp]
end
end
[false, pp]
end
def self.unpublish!(publisher, topic)
if pp = PublishedPage.find_by(topic_id: topic.id)
pp.destroy!
StaffActionLogger.new(publisher).log_unpublished_page(topic.id, pp.slug)
end
end
end

View File

@ -114,6 +114,7 @@ class Topic < ActiveRecord::Base
has_one :top_topic
has_one :shared_draft, dependent: :destroy
has_one :published_page
belongs_to :user
belongs_to :last_poster, class_name: 'User', foreign_key: :last_post_user_id

View File

@ -103,7 +103,9 @@ class UserHistory < ActiveRecord::Base
api_key_destroy: 82,
revoke_title: 83,
change_title: 84,
override_upload_secure_status: 85
override_upload_secure_status: 85,
page_published: 86,
page_unpublished: 87
)
end
@ -183,7 +185,9 @@ class UserHistory < ActiveRecord::Base
:api_key_create,
:api_key_update,
:api_key_destroy,
:override_upload_secure_status
:override_upload_secure_status,
:page_published,
:page_unpublished
]
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class PublishedPageSerializer < ApplicationSerializer
attributes :id, :slug
def id
object.topic_id
end
end

View File

@ -15,7 +15,8 @@ class TopicViewDetailsSerializer < ApplicationSerializer
:can_flag_topic,
:can_convert_topic,
:can_review_topic,
:can_edit_tags]
:can_edit_tags,
:can_publish_page]
end
attributes(
@ -133,6 +134,10 @@ class TopicViewDetailsSerializer < ApplicationSerializer
!scope.can_edit?(object.topic) && scope.can_edit_tags?(object.topic)
end
def include_can_publish_page?
scope.can_publish_page?(object.topic)
end
def allowed_users
object.topic.allowed_users.reject { |user| object.group_allowed_user_ids.include?(user.id) }
end

View File

@ -71,12 +71,14 @@ class TopicViewSerializer < ApplicationSerializer
:pm_with_non_human_user,
:queued_posts_count,
:show_read_indicator,
:requested_group_name
:requested_group_name,
)
has_one :details, serializer: TopicViewDetailsSerializer, root: false, embed: :objects
has_many :pending_posts, serializer: TopicPendingPostSerializer, root: false, embed: :objects
has_one :published_page, embed: :objects
def details
object
end
@ -273,4 +275,8 @@ class TopicViewSerializer < ApplicationSerializer
def include_requested_group_name?
object.personal_message
end
def include_published_page?
SiteSetting.enable_page_publishing? && scope.is_staff? && object.published_page.present?
end
end

View File

@ -728,6 +728,22 @@ class StaffActionLogger
))
end
def log_published_page(topic_id, slug)
UserHistory.create!(params.merge(
subject: slug,
topic_id: topic_id,
action: UserHistory.actions[:page_published]
))
end
def log_unpublished_page(topic_id, slug)
UserHistory.create!(params.merge(
subject: slug,
topic_id: topic_id,
action: UserHistory.actions[:page_unpublished]
))
end
private
def get_changes(changes)

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=yes, viewport-fit=cover">
<%= discourse_stylesheet_link_tag 'publish', theme_ids: nil %>
<%- if @canonical_url -%>
<link rel="canonical" href="<%= @canonical_url %>" />
<%- end -%>
</head>
<body>
<%= yield %>
</body>
</html>

View File

@ -0,0 +1,19 @@
<div class="published-page">
<div class="published-page-header">
<h1 class="published-page-title"><%= @topic.title %></h1>
<div class="published-page-author">
<img src="<%= @topic.user.small_avatar_url %>" class="avatar">
<div class="published-page-author-details">
<div class="username"><%= @topic.user.username %></div>
<div class="topic-created-at"><%= short_date(@topic.created_at) %></div>
</div>
</div>
<%- if @topic.first_post.present? %>
<div class="published-page-body">
<%= @topic.first_post.cooked.html_safe %>
</div>
<%- end -%>
</div>
</div>

View File

@ -2439,6 +2439,19 @@ en:
action: "merge selected posts"
error: "There was an error merging the selected posts."
publish_page:
title: "Page Publishing"
publish: "Publish"
description: "When a topic is published as a page, its URL can be shared and it will displayed with custom styling."
slug: "Slug"
publish_url: "Your page has been published at:"
topic_published: "Your topic has been published at:"
preview_url: "Your page will be published at:"
invalid_slug: "Sorry, you can't publish this page."
unpublish: "Unpublish"
unpublished: "Your page has been unpublished and is no longer accessible."
publishing_settings: "Publishing Settings"
change_owner:
title: "Change Owner"
action: "change ownership"
@ -2591,6 +2604,7 @@ en:
convert_to_moderator: "Add Staff Color"
revert_to_regular: "Remove Staff Color"
rebake: "Rebuild HTML"
publish_page: "Page Publishing"
unhide: "Unhide"
change_owner: "Change Ownership"
grant_badge: "Grant Badge"
@ -4083,6 +4097,8 @@ en:
api_key_update: "api key update"
api_key_destroy: "api key destroy"
override_upload_secure_status: "override upload secure status"
page_published: "page published"
page_unpublished: "page unpublished"
screened_emails:
title: "Screened Emails"
description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed."

View File

@ -2107,6 +2107,7 @@ en:
new_user_notice_tl: "Minimum trust level required to see new user post notices."
returning_user_notice_tl: "Minimum trust level required to see returning user post notices."
returning_users_days: "How many days should pass before a user is considered to be returning."
enable_page_publishing: "Allow staff members to publish topics to new URLs with their own styling."
default_email_digest_frequency: "How often users receive summary emails by default."
default_include_tl0_in_digests: "Include posts from new users in summary emails by default. Users can change this in their preferences."
@ -2299,6 +2300,12 @@ en:
change_owner:
post_revision_text: "Ownership transferred"
publish_page:
slug_errors:
blank: "can't be blank"
unavailable: "is unavailable"
invalid: "contains invalid characters"
topic_statuses:
autoclosed_message_max_posts:
one: "This message was automatically closed after reaching the maximum limit of %{count} reply."

View File

@ -45,6 +45,12 @@ Discourse::Application.routes.draw do
get "finish-installation/confirm-email" => "finish_installation#confirm_email"
put "finish-installation/resend-email" => "finish_installation#resend_email"
get "pub/check-slug" => "published_pages#check_slug"
get "pub/by-topic/:topic_id" => "published_pages#details"
put "pub/by-topic/:topic_id" => "published_pages#upsert"
delete "pub/by-topic/:topic_id" => "published_pages#destroy"
get "pub/:slug" => "published_pages#show"
resources :directory_items
get "site" => "site#site"

View File

@ -933,6 +933,8 @@ posting:
enum: "TrustLevelSetting"
returning_users_days:
default: 120
enable_page_publishing:
default: false
email:
email_time_window_mins:

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class CreatePublishedPages < ActiveRecord::Migration[6.0]
def change
create_table :published_pages do |t|
t.references :topic, null: false, index: { unique: true }
t.string :slug, null: false
t.timestamps
end
add_index :published_pages, :slug, unique: true
end
end

View File

@ -485,6 +485,14 @@ class Guardian
(components - Theme.components_for(parent)).empty?
end
def can_publish_page?(topic)
return false unless SiteSetting.enable_page_publishing?
return false if topic.blank?
return false if topic.private_message?
return false unless can_see_topic?(topic)
is_staff?
end
def auth_token
if cookie = request&.cookies[Auth::DefaultCurrentUserProvider::TOKEN_COOKIE]
UserAuthToken.hash_token(cookie)

View File

@ -78,7 +78,7 @@ module Stylesheet
target = nil
if !plugin_name
target_match = long.match(/admin|desktop|mobile/)
target_match = long.match(/admin|desktop|mobile|publish/)
if target_match&.length
target = target_match[0]
end

View File

@ -596,6 +596,10 @@ class TopicView
ReviewableQueuedPost.viewable_by(@user).where(topic_id: @topic.id).pending.count
end
def published_page
@topic.published_page
end
protected
def read_posts_set

View File

@ -3476,4 +3476,36 @@ describe Guardian do
expect(guardian.auth_token).to eq(token.auth_token)
end
end
describe "can_publish_page?" do
context "when disabled" do
it "is false for staff" do
expect(Guardian.new(admin).can_publish_page?(topic)).to eq(false)
end
end
context "when enabled" do
before do
SiteSetting.enable_page_publishing = true
end
it "is false for anonymous users" do
expect(Guardian.new.can_publish_page?(topic)).to eq(false)
end
it "is false for regular users" do
expect(Guardian.new(user).can_publish_page?(topic)).to eq(false)
end
it "is true for staff" do
expect(Guardian.new(moderator).can_publish_page?(topic)).to eq(true)
expect(Guardian.new(admin).can_publish_page?(topic)).to eq(true)
end
it "is false if the topic is a private message" do
post = Fabricate(:private_message_post, user: admin)
expect(Guardian.new(admin).can_publish_page?(post.topic)).to eq(false)
end
end
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
Fabricator(:published_page) do
topic
slug "published-page-test"
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe PublishedPage, type: :model do
fab!(:topic) { Fabricate(:topic) }
it "has path and url helpers" do
pp = PublishedPage.create!(topic: topic, slug: 'hello-world')
expect(pp.path).to eq("/pub/hello-world")
expect(pp.url).to eq(Discourse.base_url + "/pub/hello-world")
end
it "validates the slug" do
expect(PublishedPage.new(topic: topic, slug: "this-is-valid")).to be_valid
expect(PublishedPage.new(topic: topic, slug: "10_things_i_hate_about_slugs")).to be_valid
expect(PublishedPage.new(topic: topic, slug: "YELLING")).to be_valid
expect(PublishedPage.new(topic: topic, slug: "how about some space")).not_to be_valid
expect(PublishedPage.new(topic: topic, slug: "slugs are %%%%")).not_to be_valid
expect(PublishedPage.new(topic: topic, slug: "check-slug")).not_to be_valid
expect(PublishedPage.new(topic: topic, slug: "by-topic")).not_to be_valid
end
end

View File

@ -0,0 +1,170 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe PublishedPagesController do
fab!(:published_page) { Fabricate(:published_page) }
fab!(:admin) { Fabricate(:admin) }
fab!(:user) { Fabricate(:user) }
context "when enabled" do
before do
SiteSetting.enable_page_publishing = true
end
context "check slug availability" do
it "returns true for a new slug" do
get "/pub/check-slug.json?slug=cool-slug-man"
expect(response).to be_successful
expect(response.parsed_body["valid_slug"]).to eq(true)
end
it "returns true for a new slug with whitespace" do
get "/pub/check-slug.json?slug=cool-slug-man%20"
expect(response).to be_successful
expect(response.parsed_body["valid_slug"]).to eq(true)
end
it "returns false for an empty value" do
get "/pub/check-slug.json?slug="
expect(response).to be_successful
expect(response.parsed_body["valid_slug"]).to eq(false)
expect(response.parsed_body["reason"]).to be_present
end
it "returns false for a reserved value" do
get "/pub/check-slug.json", params: { slug: "check-slug" }
expect(response).to be_successful
expect(response.parsed_body["valid_slug"]).to eq(false)
expect(response.parsed_body["reason"]).to be_present
end
end
context "show" do
it "returns 404 for a missing article" do
get "/pub/no-article-here-no-thx"
expect(response.status).to eq(404)
end
context "private topic" do
fab!(:group) { Fabricate(:group) }
fab!(:private_category) { Fabricate(:private_category, group: group) }
before do
published_page.topic.update!(category: private_category)
end
it "returns 403 for a topic you can't see" do
get published_page.path
expect(response.status).to eq(403)
end
context "as an admin" do
before do
sign_in(admin)
end
it "returns 200" do
get published_page.path
expect(response.status).to eq(200)
end
end
end
it "returns an error for an article you can't see" do
get "/pub/no-article-here-no-thx"
expect(response.status).to eq(404)
end
it "returns 200 for a valid article" do
get published_page.path
expect(response.status).to eq(200)
end
end
context "publishing" do
fab!(:topic) { Fabricate(:topic) }
it "returns invalid access for non-staff" do
sign_in(user)
put "/pub/by-topic/#{topic.id}.json", params: { published_page: { slug: 'cant-do-this' } }
expect(response.status).to eq(403)
end
context "with a valid staff account" do
before do
sign_in(admin)
end
it "creates the published page record" do
put "/pub/by-topic/#{topic.id}.json", params: { published_page: { slug: 'i-hate-salt' } }
expect(response).to be_successful
expect(response.parsed_body['published_page']).to be_present
expect(response.parsed_body['published_page']['slug']).to eq("i-hate-salt")
expect(PublishedPage.exists?(topic_id: response.parsed_body['published_page']['id'])).to eq(true)
expect(UserHistory.exists?(
acting_user_id: admin.id,
action: UserHistory.actions[:page_published],
topic_id: topic.id
)).to be(true)
end
it "returns an error if the slug is already taken" do
PublishedPage.create!(slug: 'i-hate-salt', topic: Fabricate(:topic))
put "/pub/by-topic/#{topic.id}.json", params: { published_page: { slug: 'i-hate-salt' } }
expect(response).not_to be_successful
end
it "returns an error if the topic already has been published" do
PublishedPage.create!(slug: 'already-done-pal', topic: topic)
put "/pub/by-topic/#{topic.id}.json", params: { published_page: { slug: 'i-hate-salt' } }
expect(response).to be_successful
expect(PublishedPage.exists?(topic_id: topic.id)).to eq(true)
end
end
end
context "destroy" do
it "returns invalid access for non-staff" do
sign_in(user)
delete "/pub/by-topic/#{published_page.topic_id}.json"
expect(response.status).to eq(403)
end
context "with a valid staff account" do
before do
sign_in(admin)
end
it "deletes the record" do
topic_id = published_page.topic_id
delete "/pub/by-topic/#{topic_id}.json"
expect(response).to be_successful
expect(PublishedPage.exists?(slug: published_page.slug)).to eq(false)
expect(UserHistory.exists?(
acting_user_id: admin.id,
action: UserHistory.actions[:page_unpublished],
topic_id: topic_id
)).to be(true)
end
end
end
end
context "when disabled" do
before do
SiteSetting.enable_page_publishing = false
end
it "returns 404 for any article" do
get published_page.path
expect(response.status).to eq(404)
end
end
end

View File

@ -258,6 +258,16 @@ describe TopicViewSerializer do
expect(details[:allowed_groups].find { |ag| ag[:id] == group.id }).to be_present
end
it "has can_publish_page if possible" do
SiteSetting.enable_page_publishing = true
json = serialize_topic(topic, user)
expect(json[:details][:can_publish_page]).to be_blank
json = serialize_topic(topic, admin)
expect(json[:details][:can_publish_page]).to eq(true)
end
context "can_edit_tags" do
before do
SiteSetting.tagging_enabled = true
@ -279,4 +289,26 @@ describe TopicViewSerializer do
end
end
context "published_page" do
fab!(:published_page) { Fabricate(:published_page, topic: topic) }
it "doesn't return the published page if not enabled" do
json = serialize_topic(topic, admin)
expect(json[:published_page]).to be_blank
end
it "doesn't return the published page unless staff" do
SiteSetting.enable_page_publishing = true
json = serialize_topic(topic, user)
expect(json[:published_page]).to be_blank
end
it "returns the published page if enabled and staff" do
SiteSetting.enable_page_publishing = true
json = serialize_topic(topic, admin)
expect(json[:published_page]).to be_present
expect(json[:published_page][:slug]).to eq("published-page-test")
end
end
end

View File

@ -0,0 +1,40 @@
import { acceptance } from "helpers/qunit-helpers";
acceptance("Page Publishing", {
loggedIn: true,
pretend(server, helper) {
const validSlug = helper.response({ valid_slug: true });
server.put("/pub/by-topic/280", () => {
return helper.response({});
});
server.get("/pub/by-topic/280", () => {
return helper.response({});
});
server.get("/pub/check-slug", req => {
if (req.queryParams.slug === "internationalization-localization") {
return validSlug;
}
return helper.response({
valid_slug: false,
reason: "i don't need a reason"
});
});
}
});
QUnit.test("can publish a page via modal", async assert => {
await visit("/t/internationalization-localization/280");
await click(".topic-post:eq(0) button.show-more-actions");
await click(".topic-post:eq(0) button.show-post-admin-menu");
await click(".topic-post:eq(0) .publish-page");
await fillIn(".publish-slug", "bad-slug");
assert.ok(!exists(".valid-slug"));
assert.ok(exists(".invalid-slug"));
await fillIn(".publish-slug", "internationalization-localization");
assert.ok(exists(".valid-slug"));
assert.ok(!exists(".invalid-slug"));
await click(".publish-page");
assert.ok(exists(".current-url"));
});

View File

@ -1977,6 +1977,7 @@ export default {
pinned: false,
pinned_at: null,
details: {
can_publish_page: true,
can_invite_via_email: true,
auto_close_at: null,
auto_close_hours: null,