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:
parent
b64b590cfb
commit
e1f8014acd
|
@ -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}`;
|
||||||
|
}
|
||||||
|
});
|
|
@ -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")
|
@discourseComputed("placeholderKey")
|
||||||
placeholder: {
|
placeholder: {
|
||||||
get() {
|
get() {
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
|
@ -76,7 +76,8 @@ export function transformBasicPost(post) {
|
||||||
replyCount: post.reply_count,
|
replyCount: post.reply_count,
|
||||||
locked: post.locked,
|
locked: post.locked,
|
||||||
userCustomFields: post.user_custom_fields,
|
userCustomFields: post.user_custom_fields,
|
||||||
readCount: post.readers_count
|
readCount: post.readers_count,
|
||||||
|
canPublishPage: false
|
||||||
};
|
};
|
||||||
|
|
||||||
_additionalAttributes.forEach(a => (postAtts[a] = post[a]));
|
_additionalAttributes.forEach(a => (postAtts[a] = post[a]));
|
||||||
|
@ -118,6 +119,8 @@ export default function transformPost(
|
||||||
currentUser && (currentUser.id === post.user_id || currentUser.staff);
|
currentUser && (currentUser.id === post.user_id || currentUser.staff);
|
||||||
postAtts.canReplyAsNewTopic = details.can_reply_as_new_topic;
|
postAtts.canReplyAsNewTopic = details.can_reply_as_new_topic;
|
||||||
postAtts.canReviewTopic = !!details.can_review_topic;
|
postAtts.canReviewTopic = !!details.can_review_topic;
|
||||||
|
postAtts.canPublishPage =
|
||||||
|
!!details.can_publish_page && post.post_number === 1;
|
||||||
postAtts.isWarning = topic.is_warning;
|
postAtts.isWarning = topic.is_warning;
|
||||||
postAtts.links = post.get("internalLinks");
|
postAtts.links = post.get("internalLinks");
|
||||||
postAtts.replyDirectlyBelow =
|
postAtts.replyDirectlyBelow =
|
||||||
|
|
|
@ -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}`;
|
||||||
|
})
|
||||||
|
});
|
|
@ -545,6 +545,13 @@ const Topic = RestModel.extend({
|
||||||
this.details.updateFromJson(json.details);
|
this.details.updateFromJson(json.details);
|
||||||
|
|
||||||
keys.removeObjects(["details", "post_stream"]);
|
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]));
|
keys.forEach(key => this.set(key, json[key]));
|
||||||
},
|
},
|
||||||
|
|
|
@ -89,6 +89,14 @@ const TopicRoute = DiscourseRoute.extend({
|
||||||
controller.setProperties({ flagTopic: true });
|
controller.setProperties({ flagTopic: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
showPagePublish() {
|
||||||
|
const model = this.modelFor("topic");
|
||||||
|
showModal("publish-page", {
|
||||||
|
model,
|
||||||
|
title: "topic.publish_page.title"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
showTopicStatusUpdate() {
|
showTopicStatusUpdate() {
|
||||||
const model = this.modelFor("topic");
|
const model = this.modelFor("topic");
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
@ -85,6 +85,25 @@
|
||||||
{{topic-category topic=model class="topic-category"}}
|
{{topic-category topic=model class="topic-category"}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/topic-title}}
|
{{/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}}
|
{{/if}}
|
||||||
|
|
||||||
<div class="container posts">
|
<div class="container posts">
|
||||||
|
@ -230,7 +249,8 @@
|
||||||
selectBelow=(action "selectBelow")
|
selectBelow=(action "selectBelow")
|
||||||
fillGapBefore=(action "fillGapBefore")
|
fillGapBefore=(action "fillGapBefore")
|
||||||
fillGapAfter=(action "fillGapAfter")
|
fillGapAfter=(action "fillGapAfter")
|
||||||
showInvite=(route-action "showInvite")}}
|
showInvite=(route-action "showInvite")
|
||||||
|
showPagePublish=(route-action "showPagePublish")}}
|
||||||
{{/unless}}
|
{{/unless}}
|
||||||
|
|
||||||
{{conditional-loading-spinner condition=model.postStream.loadingBelow}}
|
{{conditional-loading-spinner condition=model.postStream.loadingBelow}}
|
||||||
|
|
|
@ -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) {
|
if (attrs.canManage) {
|
||||||
contents.push({
|
contents.push({
|
||||||
icon: "cog",
|
icon: "cog",
|
||||||
|
|
|
@ -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:not(.has-tabs) {
|
||||||
.modal-tab {
|
.modal-tab {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
@ -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
|
|
@ -114,6 +114,7 @@ class Topic < ActiveRecord::Base
|
||||||
|
|
||||||
has_one :top_topic
|
has_one :top_topic
|
||||||
has_one :shared_draft, dependent: :destroy
|
has_one :shared_draft, dependent: :destroy
|
||||||
|
has_one :published_page
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :last_poster, class_name: 'User', foreign_key: :last_post_user_id
|
belongs_to :last_poster, class_name: 'User', foreign_key: :last_post_user_id
|
||||||
|
|
|
@ -103,7 +103,9 @@ class UserHistory < ActiveRecord::Base
|
||||||
api_key_destroy: 82,
|
api_key_destroy: 82,
|
||||||
revoke_title: 83,
|
revoke_title: 83,
|
||||||
change_title: 84,
|
change_title: 84,
|
||||||
override_upload_secure_status: 85
|
override_upload_secure_status: 85,
|
||||||
|
page_published: 86,
|
||||||
|
page_unpublished: 87
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -183,7 +185,9 @@ class UserHistory < ActiveRecord::Base
|
||||||
:api_key_create,
|
:api_key_create,
|
||||||
:api_key_update,
|
:api_key_update,
|
||||||
:api_key_destroy,
|
:api_key_destroy,
|
||||||
:override_upload_secure_status
|
:override_upload_secure_status,
|
||||||
|
:page_published,
|
||||||
|
:page_unpublished
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class PublishedPageSerializer < ApplicationSerializer
|
||||||
|
attributes :id, :slug
|
||||||
|
|
||||||
|
def id
|
||||||
|
object.topic_id
|
||||||
|
end
|
||||||
|
end
|
|
@ -15,7 +15,8 @@ class TopicViewDetailsSerializer < ApplicationSerializer
|
||||||
:can_flag_topic,
|
:can_flag_topic,
|
||||||
:can_convert_topic,
|
:can_convert_topic,
|
||||||
:can_review_topic,
|
:can_review_topic,
|
||||||
:can_edit_tags]
|
:can_edit_tags,
|
||||||
|
:can_publish_page]
|
||||||
end
|
end
|
||||||
|
|
||||||
attributes(
|
attributes(
|
||||||
|
@ -133,6 +134,10 @@ class TopicViewDetailsSerializer < ApplicationSerializer
|
||||||
!scope.can_edit?(object.topic) && scope.can_edit_tags?(object.topic)
|
!scope.can_edit?(object.topic) && scope.can_edit_tags?(object.topic)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def include_can_publish_page?
|
||||||
|
scope.can_publish_page?(object.topic)
|
||||||
|
end
|
||||||
|
|
||||||
def allowed_users
|
def allowed_users
|
||||||
object.topic.allowed_users.reject { |user| object.group_allowed_user_ids.include?(user.id) }
|
object.topic.allowed_users.reject { |user| object.group_allowed_user_ids.include?(user.id) }
|
||||||
end
|
end
|
||||||
|
|
|
@ -71,12 +71,14 @@ class TopicViewSerializer < ApplicationSerializer
|
||||||
:pm_with_non_human_user,
|
:pm_with_non_human_user,
|
||||||
:queued_posts_count,
|
:queued_posts_count,
|
||||||
:show_read_indicator,
|
:show_read_indicator,
|
||||||
:requested_group_name
|
:requested_group_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
has_one :details, serializer: TopicViewDetailsSerializer, root: false, embed: :objects
|
has_one :details, serializer: TopicViewDetailsSerializer, root: false, embed: :objects
|
||||||
has_many :pending_posts, serializer: TopicPendingPostSerializer, root: false, embed: :objects
|
has_many :pending_posts, serializer: TopicPendingPostSerializer, root: false, embed: :objects
|
||||||
|
|
||||||
|
has_one :published_page, embed: :objects
|
||||||
|
|
||||||
def details
|
def details
|
||||||
object
|
object
|
||||||
end
|
end
|
||||||
|
@ -273,4 +275,8 @@ class TopicViewSerializer < ApplicationSerializer
|
||||||
def include_requested_group_name?
|
def include_requested_group_name?
|
||||||
object.personal_message
|
object.personal_message
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def include_published_page?
|
||||||
|
SiteSetting.enable_page_publishing? && scope.is_staff? && object.published_page.present?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -728,6 +728,22 @@ class StaffActionLogger
|
||||||
))
|
))
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def get_changes(changes)
|
def get_changes(changes)
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -2439,6 +2439,19 @@ en:
|
||||||
action: "merge selected posts"
|
action: "merge selected posts"
|
||||||
error: "There was an error merging the 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:
|
change_owner:
|
||||||
title: "Change Owner"
|
title: "Change Owner"
|
||||||
action: "change ownership"
|
action: "change ownership"
|
||||||
|
@ -2591,6 +2604,7 @@ en:
|
||||||
convert_to_moderator: "Add Staff Color"
|
convert_to_moderator: "Add Staff Color"
|
||||||
revert_to_regular: "Remove Staff Color"
|
revert_to_regular: "Remove Staff Color"
|
||||||
rebake: "Rebuild HTML"
|
rebake: "Rebuild HTML"
|
||||||
|
publish_page: "Page Publishing"
|
||||||
unhide: "Unhide"
|
unhide: "Unhide"
|
||||||
change_owner: "Change Ownership"
|
change_owner: "Change Ownership"
|
||||||
grant_badge: "Grant Badge"
|
grant_badge: "Grant Badge"
|
||||||
|
@ -4083,6 +4097,8 @@ en:
|
||||||
api_key_update: "api key update"
|
api_key_update: "api key update"
|
||||||
api_key_destroy: "api key destroy"
|
api_key_destroy: "api key destroy"
|
||||||
override_upload_secure_status: "override upload secure status"
|
override_upload_secure_status: "override upload secure status"
|
||||||
|
page_published: "page published"
|
||||||
|
page_unpublished: "page unpublished"
|
||||||
screened_emails:
|
screened_emails:
|
||||||
title: "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."
|
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."
|
||||||
|
|
|
@ -2107,6 +2107,7 @@ en:
|
||||||
new_user_notice_tl: "Minimum trust level required to see new user post notices."
|
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_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."
|
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_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."
|
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:
|
change_owner:
|
||||||
post_revision_text: "Ownership transferred"
|
post_revision_text: "Ownership transferred"
|
||||||
|
|
||||||
|
publish_page:
|
||||||
|
slug_errors:
|
||||||
|
blank: "can't be blank"
|
||||||
|
unavailable: "is unavailable"
|
||||||
|
invalid: "contains invalid characters"
|
||||||
|
|
||||||
topic_statuses:
|
topic_statuses:
|
||||||
autoclosed_message_max_posts:
|
autoclosed_message_max_posts:
|
||||||
one: "This message was automatically closed after reaching the maximum limit of %{count} reply."
|
one: "This message was automatically closed after reaching the maximum limit of %{count} reply."
|
||||||
|
|
|
@ -45,6 +45,12 @@ Discourse::Application.routes.draw do
|
||||||
get "finish-installation/confirm-email" => "finish_installation#confirm_email"
|
get "finish-installation/confirm-email" => "finish_installation#confirm_email"
|
||||||
put "finish-installation/resend-email" => "finish_installation#resend_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
|
resources :directory_items
|
||||||
|
|
||||||
get "site" => "site#site"
|
get "site" => "site#site"
|
||||||
|
|
|
@ -933,6 +933,8 @@ posting:
|
||||||
enum: "TrustLevelSetting"
|
enum: "TrustLevelSetting"
|
||||||
returning_users_days:
|
returning_users_days:
|
||||||
default: 120
|
default: 120
|
||||||
|
enable_page_publishing:
|
||||||
|
default: false
|
||||||
|
|
||||||
email:
|
email:
|
||||||
email_time_window_mins:
|
email_time_window_mins:
|
||||||
|
|
|
@ -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
|
|
@ -485,6 +485,14 @@ class Guardian
|
||||||
(components - Theme.components_for(parent)).empty?
|
(components - Theme.components_for(parent)).empty?
|
||||||
end
|
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
|
def auth_token
|
||||||
if cookie = request&.cookies[Auth::DefaultCurrentUserProvider::TOKEN_COOKIE]
|
if cookie = request&.cookies[Auth::DefaultCurrentUserProvider::TOKEN_COOKIE]
|
||||||
UserAuthToken.hash_token(cookie)
|
UserAuthToken.hash_token(cookie)
|
||||||
|
|
|
@ -78,7 +78,7 @@ module Stylesheet
|
||||||
|
|
||||||
target = nil
|
target = nil
|
||||||
if !plugin_name
|
if !plugin_name
|
||||||
target_match = long.match(/admin|desktop|mobile/)
|
target_match = long.match(/admin|desktop|mobile|publish/)
|
||||||
if target_match&.length
|
if target_match&.length
|
||||||
target = target_match[0]
|
target = target_match[0]
|
||||||
end
|
end
|
||||||
|
|
|
@ -596,6 +596,10 @@ class TopicView
|
||||||
ReviewableQueuedPost.viewable_by(@user).where(topic_id: @topic.id).pending.count
|
ReviewableQueuedPost.viewable_by(@user).where(topic_id: @topic.id).pending.count
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def published_page
|
||||||
|
@topic.published_page
|
||||||
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def read_posts_set
|
def read_posts_set
|
||||||
|
|
|
@ -3476,4 +3476,36 @@ describe Guardian do
|
||||||
expect(guardian.auth_token).to eq(token.auth_token)
|
expect(guardian.auth_token).to eq(token.auth_token)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
Fabricator(:published_page) do
|
||||||
|
topic
|
||||||
|
slug "published-page-test"
|
||||||
|
end
|
|
@ -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
|
|
@ -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
|
|
@ -258,6 +258,16 @@ describe TopicViewSerializer do
|
||||||
expect(details[:allowed_groups].find { |ag| ag[:id] == group.id }).to be_present
|
expect(details[:allowed_groups].find { |ag| ag[:id] == group.id }).to be_present
|
||||||
end
|
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
|
context "can_edit_tags" do
|
||||||
before do
|
before do
|
||||||
SiteSetting.tagging_enabled = true
|
SiteSetting.tagging_enabled = true
|
||||||
|
@ -279,4 +289,26 @@ describe TopicViewSerializer do
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
@ -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"));
|
||||||
|
});
|
|
@ -1977,6 +1977,7 @@ export default {
|
||||||
pinned: false,
|
pinned: false,
|
||||||
pinned_at: null,
|
pinned_at: null,
|
||||||
details: {
|
details: {
|
||||||
|
can_publish_page: true,
|
||||||
can_invite_via_email: true,
|
can_invite_via_email: true,
|
||||||
auto_close_at: null,
|
auto_close_at: null,
|
||||||
auto_close_hours: null,
|
auto_close_hours: null,
|
||||||
|
|
Loading…
Reference in New Issue