scheduled publication of pages
test plan: - enable the scheduled page publication feature in your account - create or edit a page and set a publish_at date in the near future - wait for that time (and ensure jobs are running and not backlogged) - ensure the page became published (and its module item was published if it's in a module) - ensure when a page scheduled to be published in the future is copied as part of course copy, the copy gets published at that time too - unless dates are shifted. the publish_at date should be shifted the same as the others - if a page is scheduled for publication in the near future, then the course is copied after the page is published, shifting the publish_at date into the future, the page in the destination course should be unpublished (and the calendar icon should show the publication date) - ensure when a page in a blueprint associated course is automatically published, future syncs still update the page (the auto-publish isn't counted as a downstream change that causes sync exceptions) - on the pages index, a page that is scheduled for publication but not yet published shows a red calendar icon in place of the gray circle-slash thing ordinarily seen next to unpublished pages - same goes for the modules page and the button at the top of page show - clicking the icon lets you publish the page now, unpublish it (remove the scheduled publication), or change the publication date - when a page that is scheduled for publication is included in an unpublished module, publishing the module doesn't publish the page (and you get a notice) if the feature is turned off after scheduling pages to be published, no evidence of it is visible in the UI and the page will not be published when the time arrives. (we figure this is easier than updating all pages in the account to wipe the publish_at date for a scenario that doesn't seem likely to begin with anyway) note that I removed the tooltip from the publish button on page show because it always duplicates the button text, but the tooltip remains on the publish icon closes DE-1346 flag=scheduled_page_publication Change-Id: Iba16b3d788bcb0051e022e2706446020e6b8171b Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/297715 Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Reviewed-by: Ed Schiebel <eschiebel@instructure.com> QA-Review: Ed Schiebel <eschiebel@instructure.com> Product-Review: David Lyons <lyons@instructure.com> Migration-Review: Jacob Burroughs <jburroughs@instructure.com>
This commit is contained in:
parent
06621ff012
commit
33dd2726dc
|
@ -325,7 +325,7 @@ class ApplicationController < ActionController::Base
|
|||
product_tours files_dnd usage_rights_discussion_topics
|
||||
granular_permissions_manage_users create_course_subaccount_picker
|
||||
lti_deep_linking_module_index_menu_modal lti_multiple_assignment_deep_linking buttons_and_icons_root_account
|
||||
extended_submission_state wrap_calendar_event_titles
|
||||
extended_submission_state wrap_calendar_event_titles scheduled_page_publication
|
||||
].freeze
|
||||
JS_ENV_BRAND_ACCOUNT_FEATURES = [
|
||||
:embedded_release_notes
|
||||
|
|
|
@ -732,6 +732,7 @@ class ContextModuleItemsApiController < ApplicationController
|
|||
published: new_tag.published?,
|
||||
publishable_id: module_item_publishable_id(new_tag),
|
||||
unpublishable: module_item_unpublishable?(new_tag),
|
||||
publish_at: module_item_publish_at(new_tag),
|
||||
graded: new_tag.graded?,
|
||||
content_details: content_details(new_tag, @current_user),
|
||||
assignment_id: new_tag.assignment.try(:id),
|
||||
|
|
|
@ -664,6 +664,7 @@ class ContextModulesController < ApplicationController
|
|||
published: @tag.published?,
|
||||
publishable_id: module_item_publishable_id(@tag),
|
||||
unpublishable: module_item_unpublishable?(@tag),
|
||||
publish_at: module_item_publish_at(@tag),
|
||||
graded: @tag.graded?,
|
||||
content_details: content_details(@tag, @current_user),
|
||||
assignment_id: @tag.assignment.try(:id),
|
||||
|
|
|
@ -77,6 +77,11 @@
|
|||
# "example": true,
|
||||
# "type": "boolean"
|
||||
# },
|
||||
# "publish_at": {
|
||||
# "description": "scheduled publication date for this page",
|
||||
# "example": "2022-09-01T00:00:00",
|
||||
# "type": "datetime"
|
||||
# },
|
||||
# "front_page": {
|
||||
# "description": "whether this page is the front page for the wiki",
|
||||
# "example": false,
|
||||
|
@ -326,6 +331,11 @@ class WikiPagesApiController < ApplicationController
|
|||
# @argument wiki_page[front_page] [Boolean]
|
||||
# Set an unhidden page as the front page (if true)
|
||||
#
|
||||
# @argument wiki_page[publish_at] [Optional, DateTime]
|
||||
# Schedule a future date/time to publish the page. This will have no effect unless the
|
||||
# "Scheduled Page Publication" feature is enabled in the account. If a future date is
|
||||
# supplied, the page will be unpublished and wiki_page[published] will be ignored.
|
||||
#
|
||||
# @example_request
|
||||
# curl -X POST -H 'Authorization: Bearer <token>' \
|
||||
# https://<canvas>/api/v1/courses/123/pages \
|
||||
|
@ -396,6 +406,11 @@ class WikiPagesApiController < ApplicationController
|
|||
# @argument wiki_page[published] [Boolean]
|
||||
# Whether the page is published (true) or draft state (false).
|
||||
#
|
||||
# @argument wiki_page[publish_at] [Optional, DateTime]
|
||||
# Schedule a future date/time to publish the page. This will have no effect unless the
|
||||
# "Scheduled Page Publication" feature is enabled in the account. If a future date is
|
||||
# set and the page is already published, it will be unpublished.
|
||||
#
|
||||
# @argument wiki_page[front_page] [Boolean]
|
||||
# Set an unhidden page as the front page (if true)
|
||||
#
|
||||
|
@ -611,7 +626,7 @@ class WikiPagesApiController < ApplicationController
|
|||
|
||||
def get_update_params(allowed_fields = Set[])
|
||||
# normalize parameters
|
||||
page_params = params[:wiki_page] ? params[:wiki_page].permit(*%w[title body notify_of_update published front_page editing_roles]) : {}
|
||||
page_params = params[:wiki_page] ? params[:wiki_page].permit(*%w[title body notify_of_update published front_page editing_roles publish_at]) : {}
|
||||
|
||||
if page_params.key?(:published)
|
||||
published_value = page_params.delete(:published)
|
||||
|
|
|
@ -98,6 +98,10 @@ module ContextModulesHelper
|
|||
item.content.can_publish?
|
||||
end
|
||||
|
||||
def module_item_publish_at(item)
|
||||
(item&.content.respond_to?(:publish_at) && item.content.publish_at&.iso8601) || nil
|
||||
end
|
||||
|
||||
def prerequisite_list(prerequisites)
|
||||
prerequisites.pluck(:name).join(", ")
|
||||
end
|
||||
|
|
|
@ -345,6 +345,7 @@ class ContextModule < ActiveRecord::Base
|
|||
alias_method :published?, :active?
|
||||
|
||||
def publish_items!
|
||||
enable_publish_at = context.root_account.feature_enabled?(:scheduled_page_publication)
|
||||
content_tags.each do |tag|
|
||||
if tag.unpublished?
|
||||
if tag.content_type == "Attachment"
|
||||
|
@ -352,7 +353,7 @@ class ContextModule < ActiveRecord::Base
|
|||
tag.content.save!
|
||||
tag.publish if tag.content.published?
|
||||
else
|
||||
tag.publish
|
||||
tag.publish unless enable_publish_at && tag.content.respond_to?(:publish_at) && tag.content.publish_at
|
||||
end
|
||||
end
|
||||
tag.update_asset_workflow_state!
|
||||
|
|
|
@ -384,6 +384,7 @@ module Importers
|
|||
migration.imported_migration_items_by_class(WikiPage).each do |event|
|
||||
event.reload
|
||||
event.todo_date = shift_date(event.todo_date, shift_options)
|
||||
event.publish_at = shift_date(event.publish_at, shift_options)
|
||||
event.save_without_broadcasting
|
||||
end
|
||||
|
||||
|
|
|
@ -117,6 +117,7 @@ module Importers
|
|||
end
|
||||
item.migration_id = hash[:migration_id]
|
||||
item.todo_date = Canvas::Migration::MigratorHelper.get_utc_time_from_timestamp(hash[:todo_date])
|
||||
item.publish_at = Canvas::Migration::MigratorHelper.get_utc_time_from_timestamp(hash[:publish_at])
|
||||
|
||||
migration.add_imported_item(item)
|
||||
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
#
|
||||
# Copyright (C) 2022 - present Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
module ScheduledPublication
|
||||
def self.included(klass)
|
||||
klass.send(:before_save, :process_publish_at)
|
||||
klass.send(:after_save, :schedule_delayed_publication)
|
||||
end
|
||||
|
||||
def process_publish_at
|
||||
if will_save_change_to_workflow_state?
|
||||
# explicitly publishing / unpublishing the page clears publish_at
|
||||
self.publish_at = nil unless workflow_state_in_database.nil? || @implicitly_published
|
||||
elsif will_save_change_to_publish_at? &&
|
||||
publish_at&.>(Time.now.utc) &&
|
||||
context.root_account.feature_enabled?(:scheduled_page_publication)
|
||||
# setting a publish_at date in the future unpublishes the page
|
||||
self.workflow_state = "unpublished"
|
||||
@schedule_publication = true
|
||||
end
|
||||
end
|
||||
|
||||
def schedule_delayed_publication
|
||||
delay(run_at: publish_at).publish_if_scheduled if @schedule_publication
|
||||
@schedule_publication = false
|
||||
end
|
||||
|
||||
def publish_if_scheduled
|
||||
# include a fudge factor in case clock skew between db/job servers
|
||||
# causes the job to wake up a little early
|
||||
return if published? || publish_at.nil? || publish_at > Time.now.utc + 15.seconds
|
||||
return unless context.root_account.feature_enabled?(:scheduled_page_publication)
|
||||
|
||||
@implicitly_published = true # leave publish_at alone so future course copies can shift it
|
||||
skip_downstream_changes! # ensure scheduled publication doesn't count as a downstream edit for blueprints
|
||||
publish!
|
||||
end
|
||||
end
|
|
@ -30,6 +30,7 @@ class WikiPage < ActiveRecord::Base
|
|||
validates :body, length: { maximum: maximum_long_text_length, allow_blank: true }
|
||||
validates :wiki_id, presence: true
|
||||
include Canvas::SoftDeletable
|
||||
include ScheduledPublication
|
||||
include HasContentTags
|
||||
include CopyAuthorizedLinks
|
||||
include ContextModuleItem
|
||||
|
@ -44,6 +45,7 @@ class WikiPage < ActiveRecord::Base
|
|||
restrict_columns :settings, [:editing_roles, :url]
|
||||
restrict_assignment_columns
|
||||
restrict_columns :state, [:workflow_state]
|
||||
restrict_columns :availability_dates, [:publish_at]
|
||||
|
||||
after_update :post_to_pandapub_when_revised
|
||||
|
||||
|
@ -60,6 +62,7 @@ class WikiPage < ActiveRecord::Base
|
|||
before_save :default_submission_values,
|
||||
if: proc { context.try(:conditional_release?) }
|
||||
before_save :set_revised_at
|
||||
|
||||
before_validation :ensure_wiki_and_context
|
||||
before_validation :ensure_unique_title
|
||||
before_create :set_root_account_id
|
||||
|
@ -206,6 +209,7 @@ class WikiPage < ActiveRecord::Base
|
|||
exclude_fields = [:user_id, :updated_at].concat(SIMPLY_VERSIONED_EXCLUDE_FIELDS).map(&:to_s)
|
||||
(wp.changes.keys.map(&:to_s) - exclude_fields).present?
|
||||
}
|
||||
|
||||
after_save :remove_changed_flag
|
||||
|
||||
workflow do
|
||||
|
|
|
@ -234,6 +234,7 @@
|
|||
data-published="<%= module_item && item_data[:published_status] == 'published' %>"
|
||||
data-publishable="<%= module_item_publishable?(module_item) %>"
|
||||
data-unpublishable="<%= module_item_unpublishable?(module_item) %>"
|
||||
data-publish-at="<%= module_item_publish_at(module_item) %>"
|
||||
data-publish-title="<%= module_item && module_item.title ? module_item.title : '' %>"
|
||||
title=""
|
||||
data-tooltip
|
||||
|
|
|
@ -427,3 +427,8 @@ open_todos_in_new_tab:
|
|||
state: allowed_on
|
||||
display_name: Open to-do items in a new tab
|
||||
description: When enabled, this setting automatically opens teacher to-do items in a new tab.
|
||||
scheduled_page_publication:
|
||||
applies_to: RootAccount
|
||||
state: hidden
|
||||
display_name: Scheduled page publication
|
||||
description: Allows course pages to be published on a schedule
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Copyright (C) 2022 - present Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
class AddPublishAtToWikiPages < ActiveRecord::Migration[6.1]
|
||||
tag :predeploy
|
||||
|
||||
def change
|
||||
add_column :wiki_pages, :publish_at, :datetime
|
||||
end
|
||||
end
|
|
@ -105,6 +105,8 @@ module Api::V1::ContextModule
|
|||
hash["page_url"] = content_tag.content.url
|
||||
end
|
||||
|
||||
hash["publish_at"] = content_tag.content.publish_at&.iso8601 if content_tag.content.respond_to?(:publish_at)
|
||||
|
||||
# add data-api-endpoint link, if applicable
|
||||
api_url = nil
|
||||
case content_tag.content_type
|
||||
|
|
|
@ -39,6 +39,7 @@ module Api::V1::WikiPage
|
|||
hash["front_page"] = wiki_page.is_front_page?
|
||||
hash["html_url"] = polymorphic_url([wiki_page.context, wiki_page])
|
||||
hash["todo_date"] = wiki_page.todo_date
|
||||
hash["publish_at"] = wiki_page.publish_at
|
||||
|
||||
hash["updated_at"] = wiki_page.revised_at
|
||||
if opts[:include_assignment] && wiki_page.for_assignment?
|
||||
|
|
|
@ -51,6 +51,7 @@ module CC::Importer::Canvas
|
|||
wiki[:url_name] = wiki_name
|
||||
wiki[:assignment] = nil
|
||||
wiki[:todo_date] = meta["todo_date"]
|
||||
wiki[:publish_at] = meta["publish_at"]
|
||||
if (asg_id = meta["assignment_identifier"])
|
||||
wiki[:assignment] = {
|
||||
migration_id: asg_id,
|
||||
|
|
|
@ -57,6 +57,7 @@ module CC
|
|||
meta_fields[:only_visible_to_overrides] = page.assignment.only_visible_to_overrides
|
||||
end
|
||||
meta_fields[:todo_date] = page.todo_date
|
||||
meta_fields[:publish_at] = page.publish_at
|
||||
|
||||
File.open(path, "w") do |file|
|
||||
file << @html_exporter.html_page(page.body, page.title, meta_fields)
|
||||
|
|
|
@ -295,6 +295,7 @@
|
|||
<xs:element name="only_graders_can_rate" type="xs:boolean" minOccurs="0"/>
|
||||
<xs:element name="sort_by_rating" type="xs:boolean" minOccurs="0"/>
|
||||
<xs:element name="todo_date" type="optional_dateTime" minOccurs="0"/>
|
||||
<xs:element name="publish_at" type="optional_dateTime" minOccurs="0"/>
|
||||
<xs:element name="discussion_type" minOccurs="0">
|
||||
<xs:simpleType>
|
||||
<xs:restriction base="xs:string">
|
||||
|
|
|
@ -273,6 +273,7 @@ describe "Module Items API", type: :request do
|
|||
"url" => "http://www.example.com/api/v1/courses/#{@course.id}/pages/#{@wiki_page.url}",
|
||||
"page_url" => @wiki_page.url,
|
||||
"published" => true,
|
||||
"publish_at" => nil,
|
||||
"module_id" => @module2.id,
|
||||
"quiz_lti" => false
|
||||
})
|
||||
|
|
|
@ -306,7 +306,8 @@ describe "Pages API", type: :request do
|
|||
"front_page" => false,
|
||||
"locked_for_user" => false,
|
||||
"page_id" => @hidden_page.id,
|
||||
"todo_date" => nil }
|
||||
"todo_date" => nil,
|
||||
"publish_at" => nil }
|
||||
expect(json).to eq expected
|
||||
end
|
||||
|
||||
|
@ -329,7 +330,8 @@ describe "Pages API", type: :request do
|
|||
"front_page" => true,
|
||||
"locked_for_user" => false,
|
||||
"page_id" => page.id,
|
||||
"todo_date" => nil, }
|
||||
"todo_date" => nil,
|
||||
"publish_at" => nil }
|
||||
expect(json).to eq expected
|
||||
end
|
||||
|
||||
|
@ -620,6 +622,16 @@ describe "Pages API", type: :request do
|
|||
expect(page).to be_published
|
||||
end
|
||||
|
||||
it "creates a delayed-publish page" do
|
||||
json = api_call(:post, "/api/v1/courses/#{@course.id}/pages",
|
||||
{ controller: "wiki_pages_api", action: "create", format: "json", course_id: @course.to_param },
|
||||
{ wiki_page: { published: false, publish_at: 1.day.from_now.beginning_of_day.iso8601, title: "New Wiki Page!", body: "hello new page" } })
|
||||
page = @course.wiki_pages.where(url: json["url"]).first!
|
||||
expect(page).to be_unpublished
|
||||
expect(json["published"]).to eq false
|
||||
expect(json["publish_at"]).to eq page.publish_at.iso8601
|
||||
end
|
||||
|
||||
it "allows teachers to set editing_roles" do
|
||||
@course.default_wiki_editing_roles = "teachers"
|
||||
@course.save
|
||||
|
@ -851,6 +863,15 @@ describe "Pages API", type: :request do
|
|||
expect(json["published"]).to be_falsey
|
||||
expect(@unpublished_page.reload).to be_unpublished
|
||||
end
|
||||
|
||||
it "schedules future publication" do
|
||||
json = api_call(:put, "/api/v1/courses/#{@course.id}/pages/#{@unpublished_page.url}",
|
||||
{ controller: "wiki_pages_api", action: "update", format: "json", course_id: @course.to_param, url_or_id: @unpublished_page.url },
|
||||
{ wiki_page: { "publish_at" => 1.day.from_now.beginning_of_day.iso8601 } })
|
||||
expect(@unpublished_page.reload).to be_unpublished
|
||||
expect(json["published"]).to eq false
|
||||
expect(json["publish_at"]).to eq @unpublished_page.publish_at.iso8601
|
||||
end
|
||||
end
|
||||
|
||||
it "unpublishes a page" do
|
||||
|
|
|
@ -18,38 +18,44 @@
|
|||
|
||||
import Backbone from '@canvas/backbone'
|
||||
import PublishButtonView from '@canvas/publish-button-view'
|
||||
import DelayedPublishDialog from '@canvas/publish-button-view/react/components/DelayedPublishDialog'
|
||||
import $ from 'jquery'
|
||||
import 'helpers/jquery.simulate'
|
||||
import ReactDOM from 'react-dom'
|
||||
import fakeENV from '../helpers/fakeENV'
|
||||
import sinon from 'sinon'
|
||||
|
||||
class Publishable extends Backbone.Model {
|
||||
defaults() {
|
||||
return {
|
||||
published: false,
|
||||
publishable: true,
|
||||
publish_at: null,
|
||||
disabledForModeration: false
|
||||
}
|
||||
}
|
||||
|
||||
publish() {
|
||||
this.set('published', true)
|
||||
const dfrd = $.Deferred()
|
||||
dfrd.resolve()
|
||||
return dfrd
|
||||
}
|
||||
|
||||
unpublish() {
|
||||
this.set('published', false)
|
||||
const dfrd = $.Deferred()
|
||||
dfrd.resolve()
|
||||
return dfrd
|
||||
}
|
||||
|
||||
disabledMessage() {
|
||||
return "can't unpublish"
|
||||
}
|
||||
}
|
||||
|
||||
QUnit.module('PublishButtonView', {
|
||||
setup() {
|
||||
class Publishable extends Backbone.Model {
|
||||
defaults() {
|
||||
return {
|
||||
published: false,
|
||||
publishable: true,
|
||||
disabledForModeration: false
|
||||
}
|
||||
}
|
||||
|
||||
publish() {
|
||||
this.set('published', true)
|
||||
const dfrd = $.Deferred()
|
||||
dfrd.resolve()
|
||||
return dfrd
|
||||
}
|
||||
|
||||
unpublish() {
|
||||
this.set('published', false)
|
||||
const dfrd = $.Deferred()
|
||||
dfrd.resolve()
|
||||
return dfrd
|
||||
}
|
||||
|
||||
disabledMessage() {
|
||||
return "can't unpublish"
|
||||
}
|
||||
}
|
||||
this.publishable = Publishable
|
||||
this.publish = new Publishable({published: false, unpublishable: true})
|
||||
this.published = new Publishable({published: true, unpublishable: true})
|
||||
|
@ -58,20 +64,20 @@ QUnit.module('PublishButtonView', {
|
|||
}
|
||||
})
|
||||
|
||||
test('initialize publish', function() {
|
||||
test('initialize publish', function () {
|
||||
const btnView = new PublishButtonView({model: this.publish}).render()
|
||||
ok(btnView.isPublish())
|
||||
equal(btnView.$text.html().match(/Publish/).length, 1)
|
||||
ok(!btnView.$text.html().match(/Published/))
|
||||
})
|
||||
|
||||
test('initialize published', function() {
|
||||
test('initialize published', function () {
|
||||
const btnView = new PublishButtonView({model: this.published}).render()
|
||||
ok(btnView.isPublished())
|
||||
equal(btnView.$text.html().match(/Published/).length, 1)
|
||||
})
|
||||
|
||||
test('initialize disabled published', function() {
|
||||
test('initialize disabled published', function () {
|
||||
const btnView = new PublishButtonView({model: this.disabled}).render()
|
||||
ok(btnView.isPublished())
|
||||
ok(btnView.isDisabled())
|
||||
|
@ -79,7 +85,7 @@ test('initialize disabled published', function() {
|
|||
equal(btnView.$el.attr('aria-label').match(/can't unpublish/).length, 1)
|
||||
})
|
||||
|
||||
test('should render the provided publish text when given', function() {
|
||||
test('should render the provided publish text when given', function () {
|
||||
const testText = 'Test Publish Text'
|
||||
const btnView = new PublishButtonView({
|
||||
model: this.publish,
|
||||
|
@ -88,7 +94,7 @@ test('should render the provided publish text when given', function() {
|
|||
equal(btnView.$('.screenreader-only.accessible_label').text(), testText)
|
||||
})
|
||||
|
||||
test('should render the provided unpublish text when given', function() {
|
||||
test('should render the provided unpublish text when given', function () {
|
||||
const testText = 'Test Unpublish Text'
|
||||
const btnView = new PublishButtonView({
|
||||
model: this.published,
|
||||
|
@ -97,7 +103,7 @@ test('should render the provided unpublish text when given', function() {
|
|||
equal(btnView.$('.screenreader-only.accessible_label').text(), testText)
|
||||
})
|
||||
|
||||
test('should render title in publish text when given', function() {
|
||||
test('should render title in publish text when given', function () {
|
||||
const btnView = new PublishButtonView({
|
||||
model: this.publish,
|
||||
title: 'My Published Thing'
|
||||
|
@ -111,7 +117,7 @@ test('should render title in publish text when given', function() {
|
|||
)
|
||||
})
|
||||
|
||||
test('should render title in unpublish test when given', function() {
|
||||
test('should render title in unpublish test when given', function () {
|
||||
const btnView = new PublishButtonView({
|
||||
model: this.published,
|
||||
title: 'My Unpublished Thing'
|
||||
|
@ -125,14 +131,14 @@ test('should render title in unpublish test when given', function() {
|
|||
)
|
||||
})
|
||||
|
||||
test('disable should add disabled state', function() {
|
||||
test('disable should add disabled state', function () {
|
||||
const btnView = new PublishButtonView({model: this.publish}).render()
|
||||
ok(!btnView.isDisabled())
|
||||
btnView.disable()
|
||||
ok(btnView.isDisabled())
|
||||
})
|
||||
|
||||
test('enable should remove disabled state', function() {
|
||||
test('enable should remove disabled state', function () {
|
||||
const btnView = new PublishButtonView({model: this.publish}).render()
|
||||
btnView.disable()
|
||||
ok(btnView.isDisabled())
|
||||
|
@ -140,7 +146,7 @@ test('enable should remove disabled state', function() {
|
|||
ok(!btnView.isDisabled())
|
||||
})
|
||||
|
||||
test('reset should disable states', function() {
|
||||
test('reset should disable states', function () {
|
||||
const btnView = new PublishButtonView({model: this.publish}).render()
|
||||
btnView.reset()
|
||||
ok(!btnView.isPublish())
|
||||
|
@ -148,70 +154,70 @@ test('reset should disable states', function() {
|
|||
ok(!btnView.isUnpublish())
|
||||
})
|
||||
|
||||
test('mouseenter publish button should remain publish button', function() {
|
||||
test('mouseenter publish button should remain publish button', function () {
|
||||
const btnView = new PublishButtonView({model: this.publish}).render()
|
||||
btnView.$el.trigger('mouseenter')
|
||||
ok(btnView.isPublish())
|
||||
})
|
||||
|
||||
test('mouseenter publish button should not change text or icon', function() {
|
||||
test('mouseenter publish button should not change text or icon', function () {
|
||||
const btnView = new PublishButtonView({model: this.publish}).render()
|
||||
btnView.$el.trigger('mouseenter')
|
||||
equal(btnView.$text.html().match(/Publish/).length, 1)
|
||||
ok(!btnView.$text.html().match(/Published/))
|
||||
})
|
||||
|
||||
test('mouseenter published button should remove published state', function() {
|
||||
test('mouseenter published button should remove published state', function () {
|
||||
const btnView = new PublishButtonView({model: this.published}).render()
|
||||
btnView.$el.trigger('mouseenter')
|
||||
ok(!btnView.isPublished())
|
||||
})
|
||||
|
||||
test('mouseenter published button should add add unpublish state', function() {
|
||||
test('mouseenter published button should add add unpublish state', function () {
|
||||
const btnView = new PublishButtonView({model: this.published}).render()
|
||||
btnView.$el.trigger('mouseenter')
|
||||
ok(btnView.isUnpublish())
|
||||
})
|
||||
|
||||
test('mouseenter published button should change icon and text', function() {
|
||||
test('mouseenter published button should change icon and text', function () {
|
||||
const btnView = new PublishButtonView({model: this.published}).render()
|
||||
btnView.$el.trigger('mouseenter')
|
||||
equal(btnView.$text.html().match(/Unpublish/).length, 1)
|
||||
})
|
||||
|
||||
test('mouseenter disabled published button should keep published state', function() {
|
||||
test('mouseenter disabled published button should keep published state', function () {
|
||||
const btnView = new PublishButtonView({model: this.disabled}).render()
|
||||
btnView.$el.trigger('mouseenter')
|
||||
ok(btnView.isPublished())
|
||||
})
|
||||
|
||||
test('mouseenter disabled published button should not change text or icon', function() {
|
||||
test('mouseenter disabled published button should not change text or icon', function () {
|
||||
const btnView = new PublishButtonView({model: this.disabled}).render()
|
||||
equal(btnView.$text.html().match(/Published/).length, 1)
|
||||
})
|
||||
|
||||
test('mouseleave published button should add published state', function() {
|
||||
test('mouseleave published button should add published state', function () {
|
||||
const btnView = new PublishButtonView({model: this.published}).render()
|
||||
btnView.$el.trigger('mouseenter')
|
||||
btnView.$el.trigger('mouseleave')
|
||||
ok(btnView.isPublished())
|
||||
})
|
||||
|
||||
test('mouseleave published button should remove unpublish state', function() {
|
||||
test('mouseleave published button should remove unpublish state', function () {
|
||||
const btnView = new PublishButtonView({model: this.published}).render()
|
||||
btnView.$el.trigger('mouseenter')
|
||||
btnView.$el.trigger('mouseleave')
|
||||
ok(!btnView.isUnpublish())
|
||||
})
|
||||
|
||||
test('mouseleave published button should change icon and text', function() {
|
||||
test('mouseleave published button should change icon and text', function () {
|
||||
const btnView = new PublishButtonView({model: this.published}).render()
|
||||
btnView.$el.trigger('mouseenter')
|
||||
btnView.$el.trigger('mouseleave')
|
||||
equal(btnView.$text.html().match(/Published/).length, 1)
|
||||
})
|
||||
|
||||
test('click publish should trigger publish event', function() {
|
||||
test('click publish should trigger publish event', function () {
|
||||
const btnView = new PublishButtonView({model: this.publish}).render()
|
||||
let triggered = false
|
||||
btnView.on('publish', () => (triggered = true))
|
||||
|
@ -219,7 +225,7 @@ test('click publish should trigger publish event', function() {
|
|||
ok(triggered)
|
||||
})
|
||||
|
||||
test('publish event callback should transition to published', function() {
|
||||
test('publish event callback should transition to published', function () {
|
||||
const btnView = new PublishButtonView({model: this.publish}).render()
|
||||
ok(btnView.isPublish())
|
||||
btnView.$el.trigger('mouseenter')
|
||||
|
@ -228,20 +234,23 @@ test('publish event callback should transition to published', function() {
|
|||
ok(btnView.isPublished())
|
||||
})
|
||||
|
||||
test('publish event callback should transition back to publish if rejected', function() {
|
||||
this.publishable.prototype.publish = function() {
|
||||
this.set('published', false)
|
||||
return $.Deferred().reject()
|
||||
}
|
||||
test('publish event callback should transition back to publish if rejected', function () {
|
||||
sinon.stub(this.publish, 'publish').callsFake(
|
||||
function () {
|
||||
this.set('published', false)
|
||||
return $.Deferred().reject()
|
||||
}.bind(this.publish)
|
||||
)
|
||||
const btnView = new PublishButtonView({model: this.publish}).render()
|
||||
ok(btnView.isPublish())
|
||||
btnView.$el.trigger('mouseenter')
|
||||
btnView.$el.trigger('click')
|
||||
ok(btnView.isPublish())
|
||||
ok(!btnView.isPublished())
|
||||
this.publish.publish.restore()
|
||||
})
|
||||
|
||||
test('click published should trigger unpublish event', function() {
|
||||
test('click published should trigger unpublish event', function () {
|
||||
const btnView = new PublishButtonView({model: this.published}).render()
|
||||
let triggered = false
|
||||
btnView.on('unpublish', () => (triggered = true))
|
||||
|
@ -250,7 +259,7 @@ test('click published should trigger unpublish event', function() {
|
|||
ok(triggered)
|
||||
})
|
||||
|
||||
test('published event callback should transition to publish', function() {
|
||||
test('published event callback should transition to publish', function () {
|
||||
const btnView = new PublishButtonView({model: this.published}).render()
|
||||
ok(btnView.isPublished())
|
||||
btnView.$el.trigger('mouseenter')
|
||||
|
@ -259,8 +268,8 @@ test('published event callback should transition to publish', function() {
|
|||
ok(btnView.isPublish())
|
||||
})
|
||||
|
||||
test('published event callback should transition back to published if rejected', function() {
|
||||
this.publishable.prototype.unpublish = function() {
|
||||
test('published event callback should transition back to published if rejected', function () {
|
||||
this.publishable.prototype.unpublish = function () {
|
||||
this.set('published', true)
|
||||
const response = {
|
||||
responseText: JSON.stringify({
|
||||
|
@ -279,7 +288,7 @@ test('published event callback should transition back to published if rejected',
|
|||
ok(btnView.isPublished())
|
||||
})
|
||||
|
||||
test('click disabled published button should not trigger publish event', function() {
|
||||
test('click disabled published button should not trigger publish event', function () {
|
||||
const btnView = new PublishButtonView({model: this.disabled}).render()
|
||||
ok(btnView.isPublished())
|
||||
btnView.$el.trigger('mouseenter')
|
||||
|
@ -287,7 +296,101 @@ test('click disabled published button should not trigger publish event', functio
|
|||
ok(!btnView.isPublish())
|
||||
})
|
||||
|
||||
test('publish button is disabled if assignment is disabled for moderation', function() {
|
||||
test('publish button is disabled if assignment is disabled for moderation', function () {
|
||||
const buttonView = new PublishButtonView({model: this.moderationDisabled}).render()
|
||||
strictEqual(buttonView.isDisabled(), true)
|
||||
})
|
||||
|
||||
QUnit.module('scheduled publish', hooks => {
|
||||
let page_item
|
||||
let module_item
|
||||
let dynamic_module_item
|
||||
|
||||
hooks.beforeEach(() => {
|
||||
sinon.stub(ReactDOM, 'render')
|
||||
sinon.stub(ReactDOM, 'unmountComponentAtNode')
|
||||
fakeENV.setup({COURSE_ID: 123, FEATURES: {scheduled_page_publication: true}})
|
||||
|
||||
page_item = new Publishable({
|
||||
published: false,
|
||||
unpublishable: true,
|
||||
publish_at: '2022-02-22T22:22:22Z',
|
||||
title: 'A page',
|
||||
url: 'a-page'
|
||||
})
|
||||
module_item = new Publishable({
|
||||
published: false,
|
||||
unpublishable: true,
|
||||
publish_at: '2022-02-22T22:22:22Z',
|
||||
module_item_name: 'A page',
|
||||
id: 'a-page'
|
||||
})
|
||||
dynamic_module_item = new Publishable({
|
||||
published: false,
|
||||
unpublishable: true,
|
||||
publish_at: '2022-02-22T22:22:22Z',
|
||||
module_item_name: 'A page',
|
||||
id: '100',
|
||||
url: 'http://example.com/courses/123/pages/a-page',
|
||||
page_url: 'a-page'
|
||||
})
|
||||
})
|
||||
|
||||
hooks.afterEach(() => {
|
||||
fakeENV.teardown()
|
||||
ReactDOM.render.restore()
|
||||
ReactDOM.unmountComponentAtNode.restore()
|
||||
})
|
||||
|
||||
test('renders calendar icon and publish-at text if scheduled to be published', () => {
|
||||
const buttonView = new PublishButtonView({model: page_item}).render()
|
||||
equal(buttonView.$text.html(), ' Will publish on Feb 22')
|
||||
ok(buttonView.$icon.attr('class').indexOf('icon-calendar-month') >= 0)
|
||||
})
|
||||
|
||||
test('supplies correct props to DelayedPublishDialog for page', () => {
|
||||
const buttonView = new PublishButtonView({model: page_item}).render()
|
||||
buttonView.$el.trigger('click')
|
||||
const args = ReactDOM.render.lastCall.args[0]
|
||||
equal(args.type, DelayedPublishDialog)
|
||||
equal(args.props.name, 'A page')
|
||||
equal(args.props.courseId, 123)
|
||||
equal(args.props.contentId, 'a-page')
|
||||
})
|
||||
|
||||
test('supplies correct props to DelayedPublishDialog for module item', () => {
|
||||
const buttonView = new PublishButtonView({model: module_item}).render()
|
||||
buttonView.$el.trigger('click')
|
||||
const args = ReactDOM.render.lastCall.args[0]
|
||||
equal(args.type, DelayedPublishDialog)
|
||||
equal(args.props.name, 'A page')
|
||||
equal(args.props.courseId, 123)
|
||||
equal(args.props.contentId, 'a-page')
|
||||
})
|
||||
|
||||
test('supplies correct props to DelayedPublishDialog for dynamic module item', () => {
|
||||
const buttonView = new PublishButtonView({model: dynamic_module_item}).render()
|
||||
buttonView.$el.trigger('click')
|
||||
const args = ReactDOM.render.lastCall.args[0]
|
||||
equal(args.type, DelayedPublishDialog)
|
||||
equal(args.props.name, 'A page')
|
||||
equal(args.props.courseId, 123)
|
||||
equal(args.props.contentId, 'a-page')
|
||||
})
|
||||
|
||||
test('switches from scheduled to published state', () => {
|
||||
const buttonView = new PublishButtonView({model: page_item}).render()
|
||||
buttonView.$el.trigger('click')
|
||||
const args = ReactDOM.render.lastCall.args[0]
|
||||
args.props.onPublish()
|
||||
ok(buttonView.isPublished())
|
||||
})
|
||||
|
||||
test('updates scheduled date', () => {
|
||||
const buttonView = new PublishButtonView({model: page_item}).render()
|
||||
buttonView.$el.trigger('click')
|
||||
const args = ReactDOM.render.lastCall.args[0]
|
||||
args.props.onUpdatePublishAt('2021-12-25T00:00:00Z')
|
||||
equal(buttonView.$text.html(), ' Will publish on Dec 25')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -20,6 +20,7 @@ import Backbone from '@canvas/backbone'
|
|||
import PublishIconView from '@canvas/publish-icon-view'
|
||||
import $ from 'jquery'
|
||||
import 'helpers/jquery.simulate'
|
||||
import fakeENV from 'helpers/fakeENV'
|
||||
|
||||
QUnit.module('PublishIconView', {
|
||||
setup() {
|
||||
|
@ -49,40 +50,64 @@ QUnit.module('PublishIconView', {
|
|||
this.publish = new Publishable({published: false, unpublishable: true})
|
||||
this.published = new Publishable({published: true, unpublishable: true})
|
||||
this.disabled = new Publishable({published: true, unpublishable: false})
|
||||
this.scheduled_publish = new Publishable({
|
||||
published: false,
|
||||
unpublishable: true,
|
||||
publish_at: '2022-02-22T22:22:22Z'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('initialize publish', function() {
|
||||
test('initialize publish', function () {
|
||||
const btnView = new PublishIconView({model: this.publish}).render()
|
||||
ok(btnView.isPublish())
|
||||
equal(btnView.$text.html().match(/Publish/).length, 1)
|
||||
ok(!btnView.$text.html().match(/Published/))
|
||||
})
|
||||
|
||||
test('initialize publish adds tooltip', function() {
|
||||
test('initialize publish adds tooltip', function () {
|
||||
const btnView = new PublishIconView({model: this.publish}).render()
|
||||
equal(btnView.$el.attr('data-tooltip'), '')
|
||||
equal(btnView.$el.data('tooltip'), 'left')
|
||||
equal(btnView.$el.attr('title'), 'Publish')
|
||||
})
|
||||
|
||||
test('initialize published', function() {
|
||||
test('initialize published', function () {
|
||||
const btnView = new PublishIconView({model: this.published}).render()
|
||||
ok(btnView.isPublished())
|
||||
equal(btnView.$text.html().match(/Published/).length, 1)
|
||||
})
|
||||
|
||||
test('initialize published adds tooltip', function() {
|
||||
test('initialize published adds tooltip', function () {
|
||||
const btnView = new PublishIconView({model: this.published}).render()
|
||||
equal(btnView.$el.attr('data-tooltip'), '')
|
||||
equal(btnView.$el.data('tooltip'), 'left')
|
||||
equal(btnView.$el.attr('title'), 'Published')
|
||||
})
|
||||
|
||||
test('initialize disabled published', function() {
|
||||
test('initialize disabled published', function () {
|
||||
const btnView = new PublishIconView({model: this.disabled}).render()
|
||||
ok(btnView.isPublished())
|
||||
ok(btnView.isDisabled())
|
||||
equal(btnView.$text.html().match(/Published/).length, 1)
|
||||
})
|
||||
|
||||
test('initialize disabled adds tooltip', function() {
|
||||
test('initialize disabled adds tooltip', function () {
|
||||
const btnView = new PublishIconView({model: this.disabled}).render()
|
||||
equal(btnView.$el.attr('data-tooltip'), '')
|
||||
equal(btnView.$el.data('tooltip'), 'left')
|
||||
equal(btnView.$el.attr('title'), "can't unpublish")
|
||||
})
|
||||
|
||||
test('initialize delayed adds tooltip', function () {
|
||||
fakeENV.setup({FEATURES: {scheduled_page_publication: true}})
|
||||
const btnView = new PublishIconView({model: this.scheduled_publish}).render()
|
||||
ok(btnView.isDelayedPublish())
|
||||
equal(btnView.$el.data('tooltip'), 'left')
|
||||
equal(btnView.$el.attr('title'), 'Will publish on Feb 22')
|
||||
fakeENV.teardown()
|
||||
})
|
||||
|
||||
test('ignores publish_at if FF is off', function () {
|
||||
const btnView = new PublishIconView({model: this.scheduled_publish}).render()
|
||||
notOk(btnView.isDelayedPublish())
|
||||
equal(btnView.$el.data('tooltip'), 'left')
|
||||
equal(btnView.$el.attr('title'), 'Publish')
|
||||
})
|
||||
|
|
|
@ -63,6 +63,10 @@ describe ContentMigration do
|
|||
@copy_from.calendar_events.create!(title: "an event",
|
||||
start_at: @old_start + 4.days,
|
||||
end_at: @old_start + 4.days + 1.hour)
|
||||
@copy_from.wiki_pages.create!(title: "a page",
|
||||
workflow_state: "unpublished",
|
||||
todo_date: @old_start + 7.days,
|
||||
publish_at: @old_start + 3.days)
|
||||
cm = @copy_from.context_modules.build(name: "some module", unlock_at: @old_start + 1.day)
|
||||
cm.save!
|
||||
|
||||
|
@ -117,6 +121,10 @@ describe ContentMigration do
|
|||
expect(new_event.start_at.to_i).to eq (@new_start + 4.days).to_i
|
||||
expect(new_event.end_at.to_i).to eq (@new_start + 4.days + 1.hour).to_i
|
||||
|
||||
new_page = @copy_to.wiki_pages.first
|
||||
expect(new_page.todo_date.to_i).to eq (@new_start + 7.days).to_i
|
||||
expect(new_page.publish_at.to_i).to eq (@new_start + 3.days).to_i
|
||||
|
||||
new_mod = @copy_to.context_modules.first
|
||||
expect(new_mod.unlock_at.to_i).to eq (@new_start + 1.day).to_i
|
||||
|
||||
|
|
|
@ -25,13 +25,14 @@ describe ContentMigration do
|
|||
|
||||
it "copies wiki page attributes" do
|
||||
page = @copy_from.wiki_pages.create!(title: "title", body: "<address><ul></ul></address>",
|
||||
editing_roles: "teachers", todo_date: Time.zone.now)
|
||||
editing_roles: "teachers", todo_date: Time.zone.now,
|
||||
publish_at: 1.week.from_now.beginning_of_day)
|
||||
|
||||
run_course_copy
|
||||
|
||||
page_to = @copy_to.wiki_pages.where(migration_id: mig_id(page)).first
|
||||
|
||||
attrs = %i[title body editing_roles todo_date]
|
||||
attrs = %i[title body editing_roles todo_date publish_at]
|
||||
expect(page.attributes.slice(*attrs)).to eq page_to.attributes.slice(*attrs)
|
||||
expect(page_to.body.strip).to eq "<address><ul></ul></address>"
|
||||
end
|
||||
|
|
|
@ -86,6 +86,28 @@ describe ContextModule do
|
|||
expect(@file.reload.published?).to eql(true)
|
||||
end
|
||||
end
|
||||
|
||||
context "with scheduled page publication" do
|
||||
before :once do
|
||||
@page1 = @course.wiki_pages.create!(title: "foo", workflow_state: "unpublished")
|
||||
@page2 = @course.wiki_pages.create!(title: "baz", workflow_state: "unpublished", publish_at: 1.week.from_now)
|
||||
@module.add_item(id: @page1.id, type: "page")
|
||||
@module.add_item(id: @page2.id, type: "page")
|
||||
end
|
||||
|
||||
it "doesn't publish pages that are scheduled to be published" do
|
||||
@course.root_account.enable_feature!(:scheduled_page_publication)
|
||||
@module.publish_items!
|
||||
expect(@page1.reload).to be_published
|
||||
expect(@page2.reload).not_to be_published
|
||||
end
|
||||
|
||||
it "ignores publish_at if the FF is off" do
|
||||
@module.publish_items!
|
||||
expect(@page1.reload).to be_published
|
||||
expect(@page2.reload).to be_published
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "can_be_duplicated?" do
|
||||
|
|
|
@ -235,6 +235,91 @@ describe WikiPage do
|
|||
end
|
||||
end
|
||||
|
||||
context "publish_at" do
|
||||
before :once do
|
||||
course_with_teacher
|
||||
@page = @course.wiki_pages.create(title: "unpublished page", workflow_state: "unpublished")
|
||||
@course.root_account.enable_feature! :scheduled_page_publication
|
||||
end
|
||||
|
||||
it "schedules a job to publish a page" do
|
||||
@page.publish_at = 1.hour.from_now
|
||||
@page.save!
|
||||
|
||||
run_jobs
|
||||
expect(@page.reload).to be_unpublished
|
||||
|
||||
Timecop.travel(61.minutes.from_now) do
|
||||
run_jobs
|
||||
expect(@page.reload).to be_published
|
||||
expect(@page.publish_at).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
it "doesn't publish prematurely if the publish_at date changes" do
|
||||
@page.update publish_at: 2.hours.from_now
|
||||
@page.update publish_at: 4.hours.from_now
|
||||
|
||||
Timecop.travel(3.hours.from_now) do
|
||||
run_jobs
|
||||
expect(@page.reload).to be_unpublished
|
||||
end
|
||||
end
|
||||
|
||||
it "unpublishes when a future publish_at date is set" do
|
||||
@page.publish!
|
||||
mod = @course.context_modules.create! name: "the module"
|
||||
tag = mod.add_item type: "page", id: @page.id
|
||||
expect(tag).to be_published
|
||||
|
||||
@page.publish_at = 1.hour.from_now
|
||||
@page.save!
|
||||
expect(@page.reload).to be_unpublished
|
||||
expect(tag.reload).to be_unpublished
|
||||
|
||||
Timecop.travel(61.minutes.from_now) do
|
||||
run_jobs
|
||||
expect(@page.reload).to be_published
|
||||
expect(tag.reload).to be_published
|
||||
end
|
||||
end
|
||||
|
||||
it "clears a publish_at date when manually publishing" do
|
||||
@page.publish_at = 1.hour.from_now
|
||||
@page.save!
|
||||
|
||||
@page.publish!
|
||||
expect(@page.reload.publish_at).to be_nil
|
||||
end
|
||||
|
||||
it "clears a publish_at date when manually unpublishing" do
|
||||
@page.publish_at = 1.hour.from_now
|
||||
@page.save!
|
||||
|
||||
Timecop.travel(61.minutes.from_now) do
|
||||
run_jobs
|
||||
expect(@page.reload).to be_published
|
||||
expect(@page.publish_at).not_to be_nil
|
||||
|
||||
new_page = WikiPage.find(@page.id)
|
||||
new_page.unpublish!
|
||||
expect(new_page.reload.publish_at).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
it "doesn't publish if the FF is off" do
|
||||
@course.root_account.disable_feature! :scheduled_page_publication
|
||||
|
||||
@page.publish_at = 1.hour.from_now
|
||||
@page.save!
|
||||
|
||||
Timecop.travel(61.minutes.from_now) do
|
||||
run_jobs
|
||||
expect(@page.reload).to be_unpublished
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#can_edit_page?" do
|
||||
it "is true if the user has manage_wiki_update rights" do
|
||||
course_with_teacher(active_all: true)
|
||||
|
|
|
@ -31,6 +31,7 @@ export default class PublishableModuleItem extends Model {
|
|||
published: true,
|
||||
publishable: true,
|
||||
unpublishable: true,
|
||||
publish_at: null,
|
||||
module_item_name: null
|
||||
}
|
||||
|
||||
|
|
|
@ -2011,13 +2011,14 @@ modules.initModuleManagement = function (duplicate) {
|
|||
const publishData = {
|
||||
moduleType: data.type,
|
||||
id: data.publishable_id,
|
||||
moduleItemName: data.moduleItemName,
|
||||
moduleItemName: data.moduleItemName || data.title,
|
||||
moduleItemId: data.id,
|
||||
moduleId: data.context_module_id,
|
||||
courseId: data.context_id,
|
||||
published: data.published,
|
||||
publishable: data.publishable,
|
||||
unpublishable: data.unpublishable,
|
||||
publishAt: data.publish_at,
|
||||
content_details: data.content_details,
|
||||
isNew: true
|
||||
}
|
||||
|
@ -2097,7 +2098,8 @@ modules.initModuleManagement = function (duplicate) {
|
|||
course_id: data.courseId,
|
||||
published: data.published,
|
||||
publishable: data.publishable,
|
||||
unpublishable: data.unpublishable
|
||||
unpublishable: data.unpublishable,
|
||||
publish_at: data.publishAt
|
||||
})
|
||||
|
||||
const viewOptions = {
|
||||
|
|
|
@ -20,6 +20,10 @@ import $ from 'jquery'
|
|||
import Backbone from '@canvas/backbone'
|
||||
import htmlEscape from 'html-escape'
|
||||
import '@canvas/forms/jquery/jquery.instructure_forms'
|
||||
import tz from '@canvas/timezone'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import DelayedPublishDialog from '@canvas/publish-button-view/react/components/DelayedPublishDialog'
|
||||
|
||||
I18n = useI18nScope('publish_btn_module')
|
||||
|
||||
|
@ -49,6 +53,7 @@ export default class PublishButton extends Backbone.View
|
|||
els:
|
||||
'i': '$icon'
|
||||
'.publish-text': '$text'
|
||||
'.dpd-mount': '$dpd_mount'
|
||||
|
||||
initialize: ->
|
||||
super
|
||||
|
@ -57,12 +62,13 @@ export default class PublishButton extends Backbone.View
|
|||
|
||||
setElement: ->
|
||||
super
|
||||
@$el.attr 'data-tooltip', ''
|
||||
@disable() if !@model.get('unpublishable') && @model.get('published')
|
||||
|
||||
# events
|
||||
|
||||
hover: ({type}) ->
|
||||
return if @isDelayedPublish()
|
||||
|
||||
if type is 'mouseenter'
|
||||
return if @keepState or @isPublish() or @isDisabled()
|
||||
@renderUnpublish()
|
||||
|
@ -72,6 +78,8 @@ export default class PublishButton extends Backbone.View
|
|||
@renderPublished() unless @isPublish() or @isDisabled()
|
||||
|
||||
click: (event) ->
|
||||
return @openDelayedPublishDialog() if @isDelayedPublish()
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return if @isDisabled()
|
||||
|
@ -130,6 +138,9 @@ export default class PublishButton extends Backbone.View
|
|||
isDisabled: ->
|
||||
@$el.hasClass @disabledClass
|
||||
|
||||
isDelayedPublish: ->
|
||||
ENV?.FEATURES?.scheduled_page_publication && !@model.get('published') && @model.get('publish_at')
|
||||
|
||||
disable: ->
|
||||
@$el.addClass @disabledClass
|
||||
|
||||
|
@ -137,9 +148,9 @@ export default class PublishButton extends Backbone.View
|
|||
@$el.removeClass @disabledClass
|
||||
|
||||
reset: ->
|
||||
@$el.removeClass "#{@publishClass} #{@publishedClass} #{@unpublishClass}"
|
||||
@$el.removeClass "#{@publishClass} #{@publishedClass} #{@unpublishClass} published-status restricted"
|
||||
@$icon.removeClass 'icon-publish icon-unpublish icon-unpublished'
|
||||
@$el.removeAttr 'aria-label'
|
||||
@$el.removeAttr 'title aria-label'
|
||||
|
||||
publishLabel: ->
|
||||
return @publishText if @publishText
|
||||
|
@ -158,7 +169,7 @@ export default class PublishButton extends Backbone.View
|
|||
@$el.attr 'role', 'button'
|
||||
|
||||
@$el.attr 'tabindex', '0'
|
||||
@$el.html '<i></i><span class="publish-text"></span>'
|
||||
@$el.html '<i></i><span class="publish-text"></span><span class="dpd-mount"></span>'
|
||||
@cacheEls()
|
||||
|
||||
# don't read text of button with screenreader
|
||||
|
@ -166,6 +177,8 @@ export default class PublishButton extends Backbone.View
|
|||
|
||||
if @model.get('published')
|
||||
@renderPublished()
|
||||
else if @isDelayedPublish()
|
||||
@renderDelayedPublish()
|
||||
else
|
||||
@renderPublish()
|
||||
@
|
||||
|
@ -207,6 +220,12 @@ export default class PublishButton extends Backbone.View
|
|||
buttonClass: @unpublishClass
|
||||
iconClass: 'icon-unpublished'
|
||||
|
||||
renderDelayedPublish: =>
|
||||
@renderState
|
||||
text: I18n.t('Will publish on %{publish_date}', {publish_date: tz.format(@model.get('publish_at'), 'date.formats.short')})
|
||||
iconClass: 'icon-calendar-month'
|
||||
buttonClass: if @$el.is("button") then '' else 'published-status restricted'
|
||||
|
||||
renderState: (options) ->
|
||||
@reset()
|
||||
@$el.addClass options.buttonClass
|
||||
|
@ -240,3 +259,17 @@ export default class PublishButton extends Backbone.View
|
|||
@$el.attr 'title', message
|
||||
@$el.data 'tooltip', 'left'
|
||||
@addAriaLabel(message)
|
||||
|
||||
openDelayedPublishDialog: () ->
|
||||
props =
|
||||
name: @model.get('title') || @model.get('module_item_name')
|
||||
courseId: ENV.COURSE_ID
|
||||
contentId: @model.get('page_url') || @model.get('url') || @model.get('id')
|
||||
publishAt: @model.get('publish_at')
|
||||
onPublish: () => @publish()
|
||||
onUpdatePublishAt: (val) =>
|
||||
@model.set('publish_at', val)
|
||||
@render()
|
||||
@setFocusToElement()
|
||||
onClose: () => ReactDOM.unmountComponentAtNode @$dpd_mount[0]
|
||||
ReactDOM.render React.createElement(DelayedPublishDialog, props), @$dpd_mount[0]
|
||||
|
|
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* Copyright (C) 2022 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useScope as useI18nScope} from '@canvas/i18n'
|
||||
import React, {useState} from 'react'
|
||||
import CanvasModal from '@canvas/instui-bindings/react/Modal'
|
||||
import {RadioInput, RadioInputGroup} from '@instructure/ui-radio-input'
|
||||
import {Button} from '@instructure/ui-buttons'
|
||||
import {IconUnpublishedSolid, IconCompleteSolid, IconCalendarMonthLine} from '@instructure/ui-icons'
|
||||
import {DateTime} from '@instructure/ui-i18n'
|
||||
import {View} from '@instructure/ui-view'
|
||||
import {Spinner} from '@instructure/ui-spinner'
|
||||
import {Alert} from '@instructure/ui-alerts'
|
||||
import CanvasDateInput from '@canvas/datetime/react/components/DateInput'
|
||||
import useDateTimeFormat from '@canvas/use-date-time-format-hook'
|
||||
import doFetchApi from '@canvas/do-fetch-api-effect'
|
||||
|
||||
const I18n = useI18nScope('publish_btn_module')
|
||||
|
||||
export default function DelayedPublishDialog({
|
||||
name,
|
||||
courseId,
|
||||
contentId,
|
||||
publishAt,
|
||||
onPublish,
|
||||
onUpdatePublishAt,
|
||||
onClose,
|
||||
timeZone
|
||||
}) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [selectedDate, setSelectedDate] = useState(publishAt)
|
||||
const [publishState, setPublishState] = useState('scheduled')
|
||||
|
||||
const changePublishAt = newDate => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
doFetchApi({
|
||||
method: 'PUT',
|
||||
path: `/api/v1/courses/${courseId}/pages/${contentId}`,
|
||||
params: {wiki_page: {publish_at: newDate}}
|
||||
})
|
||||
.then(() => {
|
||||
setOpen(false)
|
||||
onUpdatePublishAt(newDate)
|
||||
onClose()
|
||||
})
|
||||
.catch(_error => {
|
||||
setLoading(false)
|
||||
setError(true)
|
||||
})
|
||||
}
|
||||
|
||||
const onSubmit = e => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
switch (publishState) {
|
||||
case 'published':
|
||||
setOpen(false)
|
||||
onPublish()
|
||||
onClose()
|
||||
break
|
||||
case 'unpublished':
|
||||
changePublishAt(null)
|
||||
break
|
||||
case 'scheduled':
|
||||
changePublishAt(selectedDate)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<>
|
||||
{loading && <Spinner renderTitle={I18n.t('Updating publication date')} />}
|
||||
<Button
|
||||
interaction={loading ? 'disabled' : 'enabled'}
|
||||
onClick={onClose}
|
||||
margin="0 x-small 0 0"
|
||||
>
|
||||
{I18n.t('Cancel')}
|
||||
</Button>
|
||||
<Button interaction={loading ? 'disabled' : 'enabled'} color="primary" type="submit">
|
||||
{I18n.t('OK')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const tz = timeZone || ENV?.TIMEZONE || DateTime.browserTimeZone()
|
||||
const formatDate = useDateTimeFormat('date.formats.full_with_weekday', tz)
|
||||
|
||||
return (
|
||||
<CanvasModal
|
||||
as="form"
|
||||
padding="large"
|
||||
open={open}
|
||||
onDismiss={onClose}
|
||||
onSubmit={onSubmit}
|
||||
label={I18n.t('Publication Options')}
|
||||
shouldCloseOnDocumentClick={false}
|
||||
footer={<Footer />}
|
||||
>
|
||||
{error && <Alert variant="error">{I18n.t('Failed to update publication date')}</Alert>}
|
||||
<RadioInputGroup
|
||||
onChange={(_, val) => setPublishState(val)}
|
||||
name="publish_state"
|
||||
defaultValue={publishState}
|
||||
description={I18n.t('Options for %{name}', {name})}
|
||||
>
|
||||
<RadioInput
|
||||
key="published"
|
||||
value="published"
|
||||
label={
|
||||
<>
|
||||
<IconCompleteSolid color="success" /> {I18n.t('Published')}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<RadioInput
|
||||
key="unpublished"
|
||||
value="unpublished"
|
||||
label={
|
||||
<>
|
||||
<IconUnpublishedSolid /> {I18n.t('Unpublished')}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<RadioInput
|
||||
key="scheduled"
|
||||
value="scheduled"
|
||||
label={
|
||||
<>
|
||||
<IconCalendarMonthLine color="warning" /> {I18n.t('Scheduled for publication')}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<View as="div" padding="0 0 0 large">
|
||||
<CanvasDateInput
|
||||
timezone={tz}
|
||||
selectedDate={selectedDate}
|
||||
formatDate={formatDate}
|
||||
width="17rem"
|
||||
onSelectedDateChange={date => setSelectedDate(date.toISOString())}
|
||||
/>
|
||||
</View>
|
||||
</RadioInputGroup>
|
||||
</CanvasModal>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* Copyright (C) 2022 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import {render, fireEvent} from '@testing-library/react'
|
||||
import DelayedPublishDialog from '../DelayedPublishDialog'
|
||||
import doFetchApi from '@canvas/do-fetch-api-effect'
|
||||
|
||||
jest.mock('@canvas/do-fetch-api-effect')
|
||||
|
||||
const flushPromises = () => new Promise(setTimeout)
|
||||
|
||||
const fakePage = {
|
||||
url: 'a-page',
|
||||
title: 'A Page',
|
||||
page_id: 1281,
|
||||
published: false,
|
||||
created_at: '2022-08-02T18:51:44Z',
|
||||
updated_at: '2022-08-09T22:13:35Z',
|
||||
publish_at: '2022-11-01T22:00:00Z',
|
||||
body: '<p>Hello</p>'
|
||||
}
|
||||
|
||||
function renderDialog(props) {
|
||||
return render(
|
||||
<DelayedPublishDialog
|
||||
name={props.name || 'A Page'}
|
||||
courseId={props.courseId || 123}
|
||||
contentId={props.contentId || 'a-page'}
|
||||
publishAt={props.publishAt || '2022-02-22T22:22:22Z'}
|
||||
onPublish={props.onPublish || jest.fn()}
|
||||
onUpdatePublishAt={props.onUpdatePublishAt || jest.fn()}
|
||||
onClose={props.onClose || jest.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('DelayedPublishDialog', () => {
|
||||
beforeAll(() => {
|
||||
doFetchApi.mockResolvedValue(fakePage)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
doFetchApi.mockClear()
|
||||
})
|
||||
|
||||
it('shows the publish-at date', async () => {
|
||||
const {getByLabelText} = renderDialog({publishAt: '2022-03-03T14:00:00'})
|
||||
const input = getByLabelText('Choose a date')
|
||||
expect(input.value).toEqual('Thu, Mar 3, 2022, 2:00 PM')
|
||||
})
|
||||
|
||||
it('publishes a page outright', async () => {
|
||||
const onPublish = jest.fn()
|
||||
const {getByLabelText, getByRole} = renderDialog({onPublish})
|
||||
fireEvent.click(getByLabelText('Published'))
|
||||
fireEvent.click(getByRole('button', {name: 'OK'}))
|
||||
expect(onPublish).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels scheduled publication', async () => {
|
||||
const onUpdatePublishAt = jest.fn()
|
||||
const {getByLabelText, getByRole} = renderDialog({onUpdatePublishAt})
|
||||
fireEvent.click(getByLabelText('Unpublished'))
|
||||
fireEvent.click(getByRole('button', {name: 'OK'}))
|
||||
await flushPromises()
|
||||
expect(doFetchApi).toHaveBeenCalledWith({
|
||||
path: '/api/v1/courses/123/pages/a-page',
|
||||
method: 'PUT',
|
||||
params: {wiki_page: {publish_at: null}}
|
||||
})
|
||||
expect(onUpdatePublishAt).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('changes the scheduled publication date', async () => {
|
||||
const onUpdatePublishAt = jest.fn()
|
||||
const {getByLabelText, getByRole} = renderDialog({onUpdatePublishAt})
|
||||
fireEvent.click(getByLabelText('Scheduled for publication'))
|
||||
const input = getByLabelText('Choose a date')
|
||||
fireEvent.change(input, {target: {value: '2022-03-03T00:00:00.000Z'}})
|
||||
fireEvent.blur(input)
|
||||
fireEvent.click(getByRole('button', {name: 'OK'}))
|
||||
await flushPromises()
|
||||
expect(doFetchApi).toHaveBeenCalledWith({
|
||||
path: '/api/v1/courses/123/pages/a-page',
|
||||
method: 'PUT',
|
||||
params: {wiki_page: {publish_at: '2022-03-03T00:00:00.000Z'}}
|
||||
})
|
||||
expect(onUpdatePublishAt).toHaveBeenCalledWith('2022-03-03T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('closes', async () => {
|
||||
const onClose = jest.fn()
|
||||
const {getByRole} = renderDialog({onClose})
|
||||
fireEvent.click(getByRole('button', {name: 'Close'}))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
|
@ -37,4 +37,8 @@ export default class PublishIconView extends PublishButtonView
|
|||
super
|
||||
@events = _.extend({}, PublishButtonView.prototype.events, @events)
|
||||
|
||||
setElement: ->
|
||||
super
|
||||
@$el.attr 'data-tooltip', ''
|
||||
|
||||
events: {'keyclick' : 'click'}
|
||||
|
|
|
@ -182,6 +182,18 @@ export default class WikiPageEditView extends ValidatedFormView {
|
|||
if (!this.firstRender) {
|
||||
this.firstRender = true
|
||||
$(() => $('[autofocus]:not(:focus)').eq(0).focus())
|
||||
|
||||
const publishAtInput = $('#publish_at_input')
|
||||
if (this.model.get('published')) {
|
||||
publishAtInput.prop('disabled', true)
|
||||
} else {
|
||||
publishAtInput
|
||||
.datetime_field()
|
||||
.change(e => {
|
||||
$('.save_and_publish').prop('disabled', e.target.value.length > 0)
|
||||
})
|
||||
.trigger('change')
|
||||
}
|
||||
}
|
||||
|
||||
this.reloadPending = false
|
||||
|
@ -316,6 +328,10 @@ export default class WikiPageEditView extends ValidatedFormView {
|
|||
page_data.student_todo_at = null
|
||||
}
|
||||
|
||||
if (page_data.publish_at) {
|
||||
page_data.publish_at = $.unfudgeDateForProfileTimezone(page_data.publish_at)
|
||||
}
|
||||
|
||||
if (this.shouldPublish) page_data.published = true
|
||||
return page_data
|
||||
}
|
||||
|
|
|
@ -44,6 +44,18 @@
|
|||
<div id="todo_date_container" />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if CAN.PUBLISH}}
|
||||
{{#if ENV.FEATURES.scheduled_page_publication}}
|
||||
<div class="controls-section">
|
||||
<label for="publish_at_input">
|
||||
{{#t}}Publish At{{/t}}
|
||||
<input id="publish_at_input" type="text" class="datetime_field input-medium" name="publish_at"
|
||||
value="{{datetimeFormattedWithTz publish_at}}"
|
||||
data-tooltip='{"position":"top","force_position":"true"}' />
|
||||
</label>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if ENV.CONDITIONAL_RELEASE_SERVICE_ENABLED}}
|
||||
<div class="controls-section">
|
||||
<label class="checkbox" for="conditional_content">
|
||||
|
|
Loading…
Reference in New Issue