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:
Jeremy Stanley 2022-08-18 12:38:37 -06:00
parent 06621ff012
commit 33dd2726dc
34 changed files with 815 additions and 82 deletions

View File

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

View File

@ -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),

View File

@ -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),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(), '&nbsp;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(), '&nbsp;Will publish on Dec 25')
})
})

View File

@ -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')
})

View File

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

View File

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

View File

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

View File

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

View File

@ -31,6 +31,7 @@ export default class PublishableModuleItem extends Model {
published: true,
publishable: true,
unpublishable: true,
publish_at: null,
module_item_name: null
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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'}

View File

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

View File

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