Release notes editing UI
fixes FOO-1749 fixes FOO-1753 fixes FOO-1754 test plan: - Enable dynamo locally - See docker-compose/dynamodb.override.yml - See config/dynamic_settings.yml.example - See config/vault_contents.example - You should be able to created/edit/delete and publish/unpublish release notest check Change-Id: Id34a9abff807dbdaa884df6c0f4e4b8c43876b47 Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/263405 Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Reviewed-by: Michael Ziwisky <mziwisky@instructure.com> QA-Review: Michael Ziwisky <mziwisky@instructure.com> Product-Review: Jacob Burroughs <jburroughs@instructure.com>
This commit is contained in:
parent
dd5cf04e4c
commit
e6ca5a7208
|
@ -0,0 +1,100 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright (C) 2021 - 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 ReleaseNotesController < ApplicationController
|
||||||
|
before_action :get_context, only: %w[manage]
|
||||||
|
before_action :require_manage_release_notes
|
||||||
|
|
||||||
|
def require_manage_release_notes
|
||||||
|
require_site_admin_with_permission(:manage_release_notes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def index
|
||||||
|
notes = Api.paginate(ReleaseNote.paginated(include_langs: include_langs?), self, api_v1_release_notes_url)
|
||||||
|
render json: notes.to_json(except: include_langs? ? [] : ['langs'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
upsert(ReleaseNote.new)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
upsert(ReleaseNote.find(params.require(:id), include_langs: true))
|
||||||
|
end
|
||||||
|
|
||||||
|
def upsert(note)
|
||||||
|
note.target_roles = upsert_params[:target_roles] if upsert_params[:target_roles]
|
||||||
|
upsert_params[:show_ats]&.each { |env, time| note.set_show_at(env, Time.parse(time).utc) }
|
||||||
|
note.published = upsert_params[:published] if upsert_params.key?(:published)
|
||||||
|
upsert_params[:langs]&.each do |lang, data|
|
||||||
|
note[lang] = data
|
||||||
|
end
|
||||||
|
note.save
|
||||||
|
|
||||||
|
render json: note.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
note = ReleaseNote.find(params.require(:id), include_langs: true)
|
||||||
|
note.delete
|
||||||
|
|
||||||
|
render json: { status: 'ok' }
|
||||||
|
end
|
||||||
|
|
||||||
|
def publish
|
||||||
|
note = ReleaseNote.find(params.require(:id))
|
||||||
|
note.published = true
|
||||||
|
note.save
|
||||||
|
end
|
||||||
|
|
||||||
|
def unpublish
|
||||||
|
note = ReleaseNote.find(params.require(:id))
|
||||||
|
note.published = false
|
||||||
|
note.save
|
||||||
|
end
|
||||||
|
|
||||||
|
def manage
|
||||||
|
raise ActiveRecord::RecordNotFound unless @context.site_admin?
|
||||||
|
|
||||||
|
@page_title = t('Canvas Release Notes')
|
||||||
|
js_bundle :release_notes_edit
|
||||||
|
set_active_tab 'release_notes'
|
||||||
|
js_env({
|
||||||
|
release_notes_langs: allowed_langs,
|
||||||
|
release_notes_envs: allowed_envs,
|
||||||
|
})
|
||||||
|
render :html => "".html_safe, :layout => true
|
||||||
|
end
|
||||||
|
|
||||||
|
def upsert_params
|
||||||
|
@upsert_params ||= params.permit(:published, target_roles: [], langs: allowed_langs.map { |l| [l, ['title', 'description', 'url']]}.to_h, show_ats: allowed_envs).to_h
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed_langs
|
||||||
|
Setting.get('release_notes_langs', 'en,es,pt,nn,nl,zh').split(',')
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed_envs
|
||||||
|
Setting.get('release_notes_envs', Rails.env.production? ? 'beta,production' : Rails.env).split(',')
|
||||||
|
end
|
||||||
|
|
||||||
|
def include_langs?
|
||||||
|
!!params[:includes]&.include?('langs')
|
||||||
|
end
|
||||||
|
end
|
|
@ -1700,6 +1700,7 @@ class Account < ActiveRecord::Base
|
||||||
TAB_PLUGINS = 14
|
TAB_PLUGINS = 14
|
||||||
TAB_JOBS = 15
|
TAB_JOBS = 15
|
||||||
TAB_DEVELOPER_KEYS = 16
|
TAB_DEVELOPER_KEYS = 16
|
||||||
|
TAB_RELEASE_NOTES = 17
|
||||||
|
|
||||||
def external_tool_tabs(opts, user)
|
def external_tool_tabs(opts, user)
|
||||||
tools = ContextExternalTool.active.find_all_for(self, :account_navigation)
|
tools = ContextExternalTool.active.find_all_for(self, :account_navigation)
|
||||||
|
@ -1716,6 +1717,7 @@ class Account < ActiveRecord::Base
|
||||||
tabs << { :id => TAB_SUB_ACCOUNTS, :label => t('#account.tab_sub_accounts', "Sub-Accounts"), :css_class => 'sub_accounts', :href => :account_sub_accounts_path } if manage_settings
|
tabs << { :id => TAB_SUB_ACCOUNTS, :label => t('#account.tab_sub_accounts', "Sub-Accounts"), :css_class => 'sub_accounts', :href => :account_sub_accounts_path } if manage_settings
|
||||||
tabs << { :id => TAB_AUTHENTICATION, :label => t('#account.tab_authentication', "Authentication"), :css_class => 'authentication', :href => :account_authentication_providers_path } if root_account? && manage_settings
|
tabs << { :id => TAB_AUTHENTICATION, :label => t('#account.tab_authentication', "Authentication"), :css_class => 'authentication', :href => :account_authentication_providers_path } if root_account? && manage_settings
|
||||||
tabs << { :id => TAB_PLUGINS, :label => t("#account.tab_plugins", "Plugins"), :css_class => "plugins", :href => :plugins_path, :no_args => true } if root_account? && self.grants_right?(user, :manage_site_settings)
|
tabs << { :id => TAB_PLUGINS, :label => t("#account.tab_plugins", "Plugins"), :css_class => "plugins", :href => :plugins_path, :no_args => true } if root_account? && self.grants_right?(user, :manage_site_settings)
|
||||||
|
tabs << { :id => TAB_RELEASE_NOTES, :label => t("Release Notes"), :css_class => "release_notes", :href => :account_release_notes_manage_path } if root_account? && ReleaseNote.enabled? && self.grants_right?(user, :manage_release_notes)
|
||||||
tabs << { :id => TAB_JOBS, :label => t("#account.tab_jobs", "Jobs"), :css_class => "jobs", :href => :jobs_path, :no_args => true } if root_account? && self.grants_right?(user, :view_jobs)
|
tabs << { :id => TAB_JOBS, :label => t("#account.tab_jobs", "Jobs"), :css_class => "jobs", :href => :jobs_path, :no_args => true } if root_account? && self.grants_right?(user, :view_jobs)
|
||||||
else
|
else
|
||||||
tabs = []
|
tabs = []
|
||||||
|
|
|
@ -42,6 +42,8 @@
|
||||||
|
|
||||||
class ReleaseNote
|
class ReleaseNote
|
||||||
include ActiveModel::Dirty
|
include ActiveModel::Dirty
|
||||||
|
include ActiveModel::Serializers::JSON
|
||||||
|
|
||||||
attr_reader :id, :published, :created_at
|
attr_reader :id, :published, :created_at
|
||||||
define_attribute_methods :id, :show_ats, :target_roles, :published
|
define_attribute_methods :id, :show_ats, :target_roles, :published
|
||||||
|
|
||||||
|
@ -60,6 +62,10 @@ class ReleaseNote
|
||||||
@created_at = Time.parse(ddb_item['CreatedAt']).utc
|
@created_at = Time.parse(ddb_item['CreatedAt']).utc
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def attributes
|
||||||
|
{'id' => nil, 'show_ats' => nil, 'target_roles' => nil, 'published' => nil, 'langs' => nil}
|
||||||
|
end
|
||||||
|
|
||||||
def target_roles
|
def target_roles
|
||||||
@target_roles.freeze
|
@target_roles.freeze
|
||||||
end
|
end
|
||||||
|
@ -215,6 +221,13 @@ class ReleaseNote
|
||||||
@langs[lang] = translations
|
@langs[lang] = translations
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def langs
|
||||||
|
load_all_langs
|
||||||
|
@langs
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
def load_all_langs
|
def load_all_langs
|
||||||
return if @all_langs_loaded
|
return if @all_langs_loaded
|
||||||
|
|
||||||
|
@ -327,11 +340,15 @@ class ReleaseNote
|
||||||
|
|
||||||
def load_raw_records(records, include_langs: false)
|
def load_raw_records(records, include_langs: false)
|
||||||
ret = records.map { |it| ReleaseNote.new(it) }
|
ret = records.map { |it| ReleaseNote.new(it) }
|
||||||
ret.each(&:load_all_langs) if include_langs
|
ret.each { |it| it.send(:load_all_langs) } if include_langs
|
||||||
|
|
||||||
ret
|
ret
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def enabled?
|
||||||
|
!ddb_table_name.nil?
|
||||||
|
end
|
||||||
|
|
||||||
def settings
|
def settings
|
||||||
YAML.safe_load(Canvas::DynamicSettings.find(tree: :private)['release_notes.yml'] || '{}')
|
YAML.safe_load(Canvas::DynamicSettings.find(tree: :private)['release_notes.yml'] || '{}')
|
||||||
end
|
end
|
||||||
|
|
|
@ -306,6 +306,12 @@ class RoleOverride < ActiveRecord::Base
|
||||||
:true_for => %w(AccountAdmin),
|
:true_for => %w(AccountAdmin),
|
||||||
:available_to => %w(AccountAdmin AccountMembership),
|
:available_to => %w(AccountAdmin AccountMembership),
|
||||||
},
|
},
|
||||||
|
:manage_release_notes => {
|
||||||
|
:label => lambda { t('Manage release notes') },
|
||||||
|
:account_only => :site_admin,
|
||||||
|
:true_for => %w(AccountAdmin),
|
||||||
|
:available_to => %w(AccountAdmin AccountMembership),
|
||||||
|
},
|
||||||
:manage_master_courses => {
|
:manage_master_courses => {
|
||||||
:label => lambda { t('Blueprint Courses (create / edit / associate / delete)') },
|
:label => lambda { t('Blueprint Courses (create / edit / associate / delete)') },
|
||||||
:label_v2 => lambda { t("Blueprint Courses - add / edit / associate / delete") },
|
:label_v2 => lambda { t("Blueprint Courses - add / edit / associate / delete") },
|
||||||
|
|
|
@ -732,6 +732,8 @@ CanvasRails::Application.routes.draw do
|
||||||
get :statistics
|
get :statistics
|
||||||
end
|
end
|
||||||
resources :developer_keys, only: :index
|
resources :developer_keys, only: :index
|
||||||
|
|
||||||
|
get 'release_notes' => 'release_notes#manage', as: :release_notes_manage
|
||||||
end
|
end
|
||||||
|
|
||||||
get 'images/users/:user_id' => 'users#avatar_image', as: :avatar_image
|
get 'images/users/:user_id' => 'users#avatar_image', as: :avatar_image
|
||||||
|
@ -2198,6 +2200,16 @@ CanvasRails::Application.routes.draw do
|
||||||
get 'announcements', action: :index, as: :announcements
|
get 'announcements', action: :index, as: :announcements
|
||||||
end
|
end
|
||||||
|
|
||||||
|
scope(controller: :release_notes) do
|
||||||
|
get 'release_notes', action: :index, as: :release_notes
|
||||||
|
post 'release_notes', action: :create
|
||||||
|
get 'release_notes/latest', action: :latest
|
||||||
|
put 'release_notes/:id', action: :update
|
||||||
|
delete 'release_notes/:id', action: :destroy
|
||||||
|
put 'release_notes/:id/published', action: :publish
|
||||||
|
delete 'release_notes/:id/published', action: :unpublish
|
||||||
|
end
|
||||||
|
|
||||||
scope(controller: :rubrics_api) do
|
scope(controller: :rubrics_api) do
|
||||||
get 'accounts/:account_id/rubrics', action: :index, as: :account_rubrics
|
get 'accounts/:account_id/rubrics', action: :index, as: :account_rubrics
|
||||||
get 'accounts/:account_id/rubrics/:id', action: :show
|
get 'accounts/:account_id/rubrics/:id', action: :show
|
||||||
|
|
|
@ -0,0 +1,174 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright (C) 2021 - 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/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
require 'webmock/rspec'
|
||||||
|
|
||||||
|
describe ReleaseNotesController do
|
||||||
|
around(:each) do |example|
|
||||||
|
override_dynamic_settings(private: { canvas: { 'release_notes.yml': {
|
||||||
|
ddb_endpoint: ENV.fetch('DDB_ENDPOINT', 'http://dynamodb:8000/'),
|
||||||
|
ddb_table_name: 'canvas_test_release_notes'
|
||||||
|
}.to_json }}) do
|
||||||
|
ReleaseNotes::DevUtils.initialize_ddb_for_development!(recreate: true)
|
||||||
|
example.run
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:show_at) { Time.now.utc.change(usec: 0) - 1.hour }
|
||||||
|
|
||||||
|
let(:note) do
|
||||||
|
note = ReleaseNote.new
|
||||||
|
note.target_roles = ['student', 'ta']
|
||||||
|
note.set_show_at('test', show_at)
|
||||||
|
note.published = false
|
||||||
|
note['en'] = {
|
||||||
|
title: 'A boring title',
|
||||||
|
description: 'A boring description',
|
||||||
|
url: 'https://example.com/note0'
|
||||||
|
}
|
||||||
|
note.save
|
||||||
|
note
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
user_session(account_admin_user(account: Account.site_admin))
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'index' do
|
||||||
|
it 'should return the object without langs by default' do
|
||||||
|
the_note = note
|
||||||
|
get 'index'
|
||||||
|
res = JSON.parse(response.body)
|
||||||
|
expect(res.first['id']).to eq(the_note.id)
|
||||||
|
expect(res.first['langs']).to be_nil
|
||||||
|
end
|
||||||
|
it 'should return the object with langs with includes[]=langs' do
|
||||||
|
the_note = note
|
||||||
|
get 'index', params: { includes: ['langs'] }
|
||||||
|
res = JSON.parse(response.body)
|
||||||
|
expect(res.first['id']).to eq(the_note.id)
|
||||||
|
expect(res.first.dig('langs', 'en')&.with_indifferent_access).to eq(note['en'].with_indifferent_access)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'create' do
|
||||||
|
it 'should create a note with the expected values' do
|
||||||
|
post 'create', params: {
|
||||||
|
target_roles: ['user'],
|
||||||
|
show_ats: { 'test' => show_at },
|
||||||
|
published: true,
|
||||||
|
langs: {
|
||||||
|
en: {
|
||||||
|
title: 'A great title',
|
||||||
|
description: 'A great description',
|
||||||
|
url: 'https://example.com/note1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, as: :json
|
||||||
|
res = JSON.parse(response.body)
|
||||||
|
the_note = ReleaseNote.find(res['id'])
|
||||||
|
expect(the_note.target_roles).to eq(['user'])
|
||||||
|
expect(the_note.show_ats['test']).to eq(show_at)
|
||||||
|
expect(the_note.published).to be(true)
|
||||||
|
expect(the_note['en'][:title]).to eq('A great title')
|
||||||
|
expect(the_note['en'][:description]).to eq('A great description')
|
||||||
|
expect(the_note['en'][:url]).to eq('https://example.com/note1')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'update' do
|
||||||
|
it 'should update an existing note in the expected way' do
|
||||||
|
the_note = ReleaseNote.find(note.id)
|
||||||
|
expect(the_note.target_roles).to_not be_nil
|
||||||
|
put 'update', params: {
|
||||||
|
id: the_note.id,
|
||||||
|
target_roles: ['user'],
|
||||||
|
show_ats: { 'test' => show_at + 35.minutes },
|
||||||
|
published: true,
|
||||||
|
langs: {
|
||||||
|
en: {
|
||||||
|
title: 'A great title',
|
||||||
|
description: 'A great description',
|
||||||
|
url: 'https://example.com/note1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, as: :json
|
||||||
|
the_note = ReleaseNote.find(note.id)
|
||||||
|
expect(the_note.target_roles).to eq(['user'])
|
||||||
|
expect(the_note.show_ats['test']).to eq(show_at + 35.minutes)
|
||||||
|
expect(the_note.published).to be(true)
|
||||||
|
expect(the_note['en'][:title]).to eq('A great title')
|
||||||
|
expect(the_note['en'][:description]).to eq('A great description')
|
||||||
|
expect(the_note['en'][:url]).to eq('https://example.com/note1')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'works when not updating anything' do
|
||||||
|
the_note = ReleaseNote.find(note.id)
|
||||||
|
expect(the_note.target_roles).to_not be_nil
|
||||||
|
put 'update', params: { id: the_note.id }
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
res = JSON.parse(response.body)
|
||||||
|
expect(res['id']).to eq(the_note.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'should return 404 for non-existant notes' do
|
||||||
|
put 'update', params: { id: SecureRandom.uuid, target_roles: ['user'] }
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'destroy' do
|
||||||
|
it 'should remove an existing note' do
|
||||||
|
the_note = ReleaseNote.find(note.id)
|
||||||
|
expect(the_note).to_not be_nil
|
||||||
|
delete 'destroy', params: { id: the_note.id }
|
||||||
|
expect { ReleaseNote.find(note.id) }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'should return 404 for non-existant notes' do
|
||||||
|
delete 'destroy', params: { id: SecureRandom.uuid }
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'publish' do
|
||||||
|
it 'should publish an unpublished note' do
|
||||||
|
the_note = ReleaseNote.find(note.id)
|
||||||
|
expect(the_note.published).to eq(false)
|
||||||
|
put 'publish', params: { id: the_note.id }
|
||||||
|
the_note = ReleaseNote.find(note.id)
|
||||||
|
expect(the_note.published).to eq(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'unpublish' do
|
||||||
|
it 'should publish an unpublished note' do
|
||||||
|
the_note = ReleaseNote.find(note.id)
|
||||||
|
the_note.published = true
|
||||||
|
the_note.save
|
||||||
|
|
||||||
|
expect(the_note.published).to eq(true)
|
||||||
|
delete 'unpublish', params: { id: the_note.id }
|
||||||
|
the_note = ReleaseNote.find(note.id)
|
||||||
|
expect(the_note.published).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2021 - 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 ReactDOM from 'react-dom'
|
||||||
|
import ReleaseNotesEdit from './react'
|
||||||
|
import ready from '@instructure/ready'
|
||||||
|
|
||||||
|
ready(() => {
|
||||||
|
ReactDOM.render(
|
||||||
|
<ReleaseNotesEdit
|
||||||
|
envs={window.ENV.release_notes_envs}
|
||||||
|
langs={window.ENV.release_notes_langs}
|
||||||
|
/>,
|
||||||
|
document.getElementById('content')
|
||||||
|
)
|
||||||
|
})
|
|
@ -0,0 +1,221 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2021 - 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 I18n from 'i18n!release_notes'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import React, {useReducer, useEffect} from 'react'
|
||||||
|
import {Modal} from '@instructure/ui-modal'
|
||||||
|
import {Button, CloseButton} from '@instructure/ui-buttons'
|
||||||
|
import {Heading} from '@instructure/ui-heading'
|
||||||
|
import {TextInput} from '@instructure/ui-text-input'
|
||||||
|
import {TextArea} from '@instructure/ui-text-area'
|
||||||
|
import DateTimeInput from '@canvas/datetime/react/components/DateTimeInput'
|
||||||
|
import CanvasMultiSelect from '@canvas/multi-select'
|
||||||
|
import {FormFieldGroup} from '@instructure/ui-form-field'
|
||||||
|
import {ScreenReaderContent} from '@instructure/ui-a11y'
|
||||||
|
import {ToggleGroup} from '@instructure/ui-toggle-details'
|
||||||
|
|
||||||
|
import {roles} from './util'
|
||||||
|
|
||||||
|
const formatLanguage = new Intl.DisplayNames(['en'], {type: 'language'})
|
||||||
|
|
||||||
|
function createDefaultState() {
|
||||||
|
return {
|
||||||
|
target_roles: ['user'],
|
||||||
|
langs: {},
|
||||||
|
show_ats: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editReducer(state, action) {
|
||||||
|
if (action.action === 'RESET') {
|
||||||
|
if (action.payload) {
|
||||||
|
return action.payload
|
||||||
|
} else {
|
||||||
|
return createDefaultState()
|
||||||
|
}
|
||||||
|
} else if (action.action === 'SET_ATTR') {
|
||||||
|
return {...state, [action.payload.key]: action.payload.value}
|
||||||
|
} else if (action.action === 'SET_RELEASE_DATE') {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
show_ats: {...state.show_ats, [action.payload.env]: action.payload.value}
|
||||||
|
}
|
||||||
|
} else if (action.action === 'SET_LANG_ATTR') {
|
||||||
|
const lang = action.payload.lang
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
langs: {
|
||||||
|
...state.langs,
|
||||||
|
[lang]: {...state.langs[lang], [action.payload.key]: action.payload.value}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFormSubmittable(state) {
|
||||||
|
return state.langs.en && state.langs.en.title && state.langs.en.description
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateEditModal({open, onClose, onSubmit, currentNote, envs, langs}) {
|
||||||
|
const [state, reducer] = useReducer(editReducer, createDefaultState())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reducer({action: 'RESET', payload: currentNote})
|
||||||
|
}, [currentNote])
|
||||||
|
|
||||||
|
const label = currentNote ? I18n.t('Edit Release Note') : I18n.t('New Release Note')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
as="form"
|
||||||
|
open={open}
|
||||||
|
onDismiss={onClose}
|
||||||
|
onSubmit={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit(state)
|
||||||
|
}}
|
||||||
|
size="fullscreen"
|
||||||
|
label={label}
|
||||||
|
>
|
||||||
|
<Modal.Header>
|
||||||
|
<CloseButton
|
||||||
|
placement="end"
|
||||||
|
offset="small"
|
||||||
|
onClick={onClose}
|
||||||
|
screenReaderLabel={I18n.t('Close')}
|
||||||
|
/>
|
||||||
|
<Heading>{label}</Heading>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<FormFieldGroup
|
||||||
|
description={<ScreenReaderContent>{I18n.t('Dates')}</ScreenReaderContent>}
|
||||||
|
layout="columns"
|
||||||
|
startAt="small"
|
||||||
|
vAlign="top"
|
||||||
|
width="auto"
|
||||||
|
>
|
||||||
|
{envs.map(env => {
|
||||||
|
return (
|
||||||
|
<DateTimeInput
|
||||||
|
key={env}
|
||||||
|
description={I18n.t('Release Date for: ') + env[0].toUpperCase() + env.slice(1)}
|
||||||
|
onChange={newValue =>
|
||||||
|
reducer({action: 'SET_RELEASE_DATE', payload: {env, value: newValue}})
|
||||||
|
}
|
||||||
|
value={state.show_ats[env]}
|
||||||
|
data-testid={`show_at_input-${env}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</FormFieldGroup>
|
||||||
|
<CanvasMultiSelect
|
||||||
|
label={I18n.t('Available to')}
|
||||||
|
assistiveText={I18n.t(
|
||||||
|
'Select target groups. Type or use arrow keys to navigate. Multiple selections are allowed.'
|
||||||
|
)}
|
||||||
|
selectedOptionIds={state.target_roles}
|
||||||
|
onChange={newValue =>
|
||||||
|
reducer({action: 'SET_ATTR', payload: {key: 'target_roles', value: newValue}})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{roles.map(role => {
|
||||||
|
return (
|
||||||
|
<CanvasMultiSelect.Option id={role.id} value={role.id} key={role.id}>
|
||||||
|
{role.label}
|
||||||
|
</CanvasMultiSelect.Option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CanvasMultiSelect>
|
||||||
|
{langs.map(lang => {
|
||||||
|
const isRequired = lang === 'en'
|
||||||
|
return (
|
||||||
|
<ToggleGroup
|
||||||
|
defaultExpanded={isRequired}
|
||||||
|
summary={formatLanguage.of(lang)}
|
||||||
|
toggleLabel={I18n.t('Expand/collapse %{lang}', {lang: formatLanguage.of(lang)})}
|
||||||
|
size="small"
|
||||||
|
key={lang}
|
||||||
|
transition={false}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
renderLabel={I18n.t('Title')}
|
||||||
|
value={state.langs[lang]?.title || ''}
|
||||||
|
onChange={(_e, v) =>
|
||||||
|
reducer({action: 'SET_LANG_ATTR', payload: {lang, key: 'title', value: v}})
|
||||||
|
}
|
||||||
|
required={isRequired}
|
||||||
|
data-testid={`title_input-${lang}`}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
label={I18n.t('Description')}
|
||||||
|
value={state.langs[lang]?.description || ''}
|
||||||
|
onChange={e =>
|
||||||
|
reducer({
|
||||||
|
action: 'SET_LANG_ATTR',
|
||||||
|
payload: {lang, key: 'description', value: e.target.value}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
required={isRequired}
|
||||||
|
data-testid={`description_input-${lang}`}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
renderLabel={I18n.t('Link URL')}
|
||||||
|
value={state.langs[lang]?.url || ''}
|
||||||
|
onChange={(_e, v) =>
|
||||||
|
reducer({action: 'SET_LANG_ATTR', payload: {lang, key: 'url', value: v}})
|
||||||
|
}
|
||||||
|
type="url"
|
||||||
|
data-testid={`url_input-${lang}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</ToggleGroup>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button onClick={onClose} margin="0 x-small 0 0">
|
||||||
|
{I18n.t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!isFormSubmittable(state)}
|
||||||
|
onClick={() => onSubmit({...state, published: true})}
|
||||||
|
data-testid="submit_button"
|
||||||
|
>
|
||||||
|
{I18n.t('Save and Publish')}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" type="submit" disabled={!isFormSubmittable(state)}>
|
||||||
|
{I18n.t('Save')}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateEditModal.propTypes = {
|
||||||
|
open: PropTypes.bool.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
onSubmit: PropTypes.func.isRequired,
|
||||||
|
currentNote: PropTypes.object,
|
||||||
|
envs: PropTypes.array.isRequired,
|
||||||
|
langs: PropTypes.array.isRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateEditModal
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2021 - 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 I18n from 'i18n!release_notes'
|
||||||
|
import {Table} from '@instructure/ui-table'
|
||||||
|
|
||||||
|
import NotesTableRow from './NotesTableRow'
|
||||||
|
|
||||||
|
export default function NotesTable({notes, setPublished, editNote, deleteNote}) {
|
||||||
|
return (
|
||||||
|
<Table margin="small 0" caption={I18n.t('Release Notes')}>
|
||||||
|
<Table.Head>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.ColHeader id="en_title">{I18n.t('Title')}</Table.ColHeader>
|
||||||
|
<Table.ColHeader id="en_description">{I18n.t('Description')}</Table.ColHeader>
|
||||||
|
<Table.ColHeader id="roles">{I18n.t('Available to')}</Table.ColHeader>
|
||||||
|
<Table.ColHeader id="langs">{I18n.t('Languages')}</Table.ColHeader>
|
||||||
|
<Table.ColHeader id="en_url">{I18n.t('Link URL')}</Table.ColHeader>
|
||||||
|
<Table.ColHeader id="published">{I18n.t('Published')}</Table.ColHeader>
|
||||||
|
<Table.ColHeader id="empty">{/* Empty so the menu column looks right */}</Table.ColHeader>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Head>
|
||||||
|
<Table.Body>
|
||||||
|
{notes.map(note => (
|
||||||
|
<NotesTableRow
|
||||||
|
note={note}
|
||||||
|
togglePublished={() => setPublished(note.id, !note.published)}
|
||||||
|
editNote={editNote}
|
||||||
|
deleteNote={deleteNote}
|
||||||
|
key={note.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,87 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2021 - 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 I18n from 'i18n!release_notes'
|
||||||
|
import {
|
||||||
|
IconPublishSolid,
|
||||||
|
IconUnpublishedLine,
|
||||||
|
IconMoreLine,
|
||||||
|
IconEditLine,
|
||||||
|
IconTrashLine
|
||||||
|
} from '@instructure/ui-icons'
|
||||||
|
import {IconButton} from '@instructure/ui-buttons'
|
||||||
|
import {Table} from '@instructure/ui-table'
|
||||||
|
import {Menu} from '@instructure/ui-menu'
|
||||||
|
import {View} from '@instructure/ui-view'
|
||||||
|
import {rolesObject} from './util'
|
||||||
|
|
||||||
|
const formatLanguage = new Intl.DisplayNames(['en'], {type: 'language'})
|
||||||
|
|
||||||
|
export default function NotesTableRow({note, togglePublished, editNote, deleteNote}) {
|
||||||
|
return (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell>{note.langs.en.title}</Table.Cell>
|
||||||
|
<Table.Cell>{note.langs.en.description}</Table.Cell>
|
||||||
|
<Table.Cell>{note.target_roles.map(role => rolesObject[role]).join(', ')}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{Object.keys(note.langs)
|
||||||
|
.map(lang => formatLanguage.of(lang))
|
||||||
|
.join(', ')}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<a href={note.langs.en.url}>{note.langs.en.url}</a>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<IconButton
|
||||||
|
screenReaderLabel={note.published ? I18n.t('Published') : I18n.t('Unpublished')}
|
||||||
|
onClick={togglePublished}
|
||||||
|
withBorder={false}
|
||||||
|
withBackground={false}
|
||||||
|
>
|
||||||
|
{note.published ? <IconPublishSolid color="success" /> : <IconUnpublishedLine />}
|
||||||
|
</IconButton>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Menu
|
||||||
|
trigger={
|
||||||
|
<IconButton
|
||||||
|
screenReaderLabel={I18n.t('Menu')}
|
||||||
|
withBorder={false}
|
||||||
|
withBackground={false}
|
||||||
|
>
|
||||||
|
<IconMoreLine />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Menu.Item value="edit" onClick={() => editNote(note)}>
|
||||||
|
<IconEditLine size="x-small" />
|
||||||
|
<View padding="0 small">{I18n.t('Edit')}</View>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item value="remove" onClick={() => deleteNote(note.id)}>
|
||||||
|
<IconTrashLine size="x-small" />
|
||||||
|
<View padding="0 small">{I18n.t('Remove')}</View>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Because instui requires that the children of the body element be a "Row" element...
|
||||||
|
NotesTableRow.displayName = 'Row'
|
|
@ -0,0 +1,100 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2021 - 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} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import CreateEditModal from '../CreateEditModal'
|
||||||
|
|
||||||
|
const fancyNote = {
|
||||||
|
id: '8a8407a1-ed8f-48e6-8fe7-087bac0a8fe2',
|
||||||
|
target_roles: ['student', 'observer'],
|
||||||
|
langs: {
|
||||||
|
en: {
|
||||||
|
title: 'A super great note title',
|
||||||
|
description: 'An even better note description',
|
||||||
|
url: 'https://example.com/amazing_url'
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
title: 'A super great note title (spanish)',
|
||||||
|
description: 'An even better note description (spanish)',
|
||||||
|
url: 'https://es.example.com/amazing_url'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
show_ats: {},
|
||||||
|
published: true
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('create modal', () => {
|
||||||
|
it('It renders reasonable defaults', () => {
|
||||||
|
const {getByText} = render(
|
||||||
|
<CreateEditModal
|
||||||
|
open
|
||||||
|
onClose={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
currentNote={undefined}
|
||||||
|
envs={['test']}
|
||||||
|
langs={['en', 'es']}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(getByText('Everyone')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO unskip and finish these tests after upgrading jest/jsdom
|
||||||
|
it.skip('It blocks submission unless the basic english fields are completed', () => {
|
||||||
|
const onSubmit = jest.fn()
|
||||||
|
const {getByLabelText, getByText} = render(
|
||||||
|
<CreateEditModal
|
||||||
|
open
|
||||||
|
onClose={() => {}}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
currentNote={undefined}
|
||||||
|
envs={['test']}
|
||||||
|
langs={['en', 'es']}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(getByText('Save').closest('button')).toBeDisabled()
|
||||||
|
userEvent.type(getByLabelText('Title'), 'A great english title')
|
||||||
|
expect(getByText('Save').closest('button')).toBeDisabled()
|
||||||
|
userEvent.type(getByLabelText('Description'), 'A great english description')
|
||||||
|
expect(getByText('Save').closest('button')).not.toBeDisabled()
|
||||||
|
userEvent.type(getByLabelText('Link URL'), 'https://whatever.com')
|
||||||
|
expect(getByText('Save').closest('button')).not.toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('It submits the expected object', () => {
|
||||||
|
const onSubmit = jest.fn()
|
||||||
|
const {getByLabelText, getByText} = render(
|
||||||
|
<CreateEditModal
|
||||||
|
open
|
||||||
|
onClose={() => {}}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
currentNote={undefined}
|
||||||
|
envs={['test']}
|
||||||
|
langs={['en', 'es']}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
userEvent.type(getByLabelText('Title'), 'A great english title')
|
||||||
|
userEvent.type(getByLabelText('Description'), 'A great english description')
|
||||||
|
userEvent.type(getByLabelText('Link URL'), 'https://whatever.com')
|
||||||
|
userEvent.click(getByText('Save').closest('button'))
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,56 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2021 - 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} from '@testing-library/react'
|
||||||
|
import NotesTable from '../NotesTable'
|
||||||
|
|
||||||
|
const exampleNotes = [
|
||||||
|
{
|
||||||
|
id: 'f083d068-2329-4717-9f0d-9e5c7726cc82',
|
||||||
|
target_roles: ['user'],
|
||||||
|
langs: {
|
||||||
|
en: {
|
||||||
|
title: 'A great note title',
|
||||||
|
description: 'A really great note description',
|
||||||
|
url: 'https://example.com/great_url'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
show_ats: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8a8407a1-ed8f-48e6-8fe7-087bac0a8fe2',
|
||||||
|
target_roles: ['user'],
|
||||||
|
langs: {
|
||||||
|
en: {
|
||||||
|
title: 'A super great note title',
|
||||||
|
description: 'An even better note description',
|
||||||
|
url: 'https://example.com/amazing_url'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
show_ats: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('release notes table', () => {
|
||||||
|
it('displays one row per note', () => {
|
||||||
|
const {getByText} = render(<NotesTable notes={exampleNotes} />)
|
||||||
|
expect(getByText(exampleNotes[0].langs.en.title)).toBeInTheDocument()
|
||||||
|
expect(getByText(exampleNotes[1].langs.en.title)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,146 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2021 - 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} from '@testing-library/react'
|
||||||
|
import NotesTableRow from '../NotesTableRow'
|
||||||
|
|
||||||
|
const basicNote = {
|
||||||
|
id: 'f083d068-2329-4717-9f0d-9e5c7726cc82',
|
||||||
|
target_roles: ['user'],
|
||||||
|
langs: {
|
||||||
|
en: {
|
||||||
|
title: 'A great note title',
|
||||||
|
description: 'A really great note description',
|
||||||
|
url: 'https://example.com/great_url'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
show_ats: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fancyNote = {
|
||||||
|
id: '8a8407a1-ed8f-48e6-8fe7-087bac0a8fe2',
|
||||||
|
target_roles: ['student', 'observer'],
|
||||||
|
langs: {
|
||||||
|
en: {
|
||||||
|
title: 'A super great note title',
|
||||||
|
description: 'An even better note description',
|
||||||
|
url: 'https://example.com/amazing_url'
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
title: 'A super great note title (spanish)',
|
||||||
|
description: 'An even better note description (spanish)',
|
||||||
|
url: 'https://es.example.com/amazing_url'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
show_ats: {},
|
||||||
|
published: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// You need the <table><tbody> wrapper or validateDOMNesting gets angry
|
||||||
|
describe('release notes row', () => {
|
||||||
|
it('renders the english attributes', () => {
|
||||||
|
const {getByText} = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<NotesTableRow note={basicNote} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
expect(getByText(basicNote.langs.en.title)).toBeInTheDocument()
|
||||||
|
expect(getByText(basicNote.langs.en.description)).toBeInTheDocument()
|
||||||
|
expect(getByText(basicNote.langs.en.url)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the list of languages for one language', () => {
|
||||||
|
const {getByText} = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<NotesTableRow note={basicNote} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
expect(getByText('English')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the list of languages comma separated for multiple languages', () => {
|
||||||
|
const {getByText} = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<NotesTableRow note={fancyNote} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
expect(getByText('English, Spanish')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the list of roles for one role', () => {
|
||||||
|
const {getByText} = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<NotesTableRow note={basicNote} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
expect(getByText('Everyone')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the list of roles comma separated for multiple roles', () => {
|
||||||
|
const {getByText} = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<NotesTableRow note={fancyNote} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
expect(getByText('Students, Observers')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders unpublished if not published', () => {
|
||||||
|
const {getByText} = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<NotesTableRow note={basicNote} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
expect(getByText('Unpublished')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders published if published', () => {
|
||||||
|
const {getByText} = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<NotesTableRow note={fancyNote} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
expect(getByText('Published')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a menu to modify/delete the note', () => {
|
||||||
|
const {getByText} = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<NotesTableRow note={basicNote} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
expect(getByText('Menu')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,61 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2021 - 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} from '@testing-library/react'
|
||||||
|
import ReleaseNotesEdit from '../index'
|
||||||
|
import useFetchApi from '@canvas/use-fetch-api-hook'
|
||||||
|
|
||||||
|
jest.mock('@canvas/use-fetch-api-hook')
|
||||||
|
|
||||||
|
const exampleNote = {
|
||||||
|
id: 'f083d068-2329-4717-9f0d-9e5c7726cc82',
|
||||||
|
target_roles: ['user'],
|
||||||
|
langs: {
|
||||||
|
en: {
|
||||||
|
title: 'A great note title',
|
||||||
|
description: 'A really great note description',
|
||||||
|
url: 'https://example.com/great_url'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
show_ats: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('release notes editing parent', () => {
|
||||||
|
it('renders spinner while loading', () => {
|
||||||
|
useFetchApi.mockImplementationOnce(({loading}) => loading(true))
|
||||||
|
const {getByText} = render(<ReleaseNotesEdit envs={['test']} langs={['en', 'es']} />)
|
||||||
|
expect(getByText(/loading/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides spinner when not loading', () => {
|
||||||
|
useFetchApi.mockImplementationOnce(({loading}) => loading(false))
|
||||||
|
const {queryByText} = render(<ReleaseNotesEdit envs={['test']} langs={['en', 'es']} />)
|
||||||
|
expect(queryByText(/loading/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays table with successful retrieval and not loading', () => {
|
||||||
|
const notes = [exampleNote]
|
||||||
|
useFetchApi.mockImplementationOnce(({loading, success}) => {
|
||||||
|
loading(false)
|
||||||
|
success(notes)
|
||||||
|
})
|
||||||
|
const {getByText} = render(<ReleaseNotesEdit envs={['test']} langs={['en', 'es']} />)
|
||||||
|
expect(getByText(notes[0].langs.en.title)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,162 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2021 - 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, {useReducer, useState, useCallback} from 'react'
|
||||||
|
import {cloneDeep} from 'lodash'
|
||||||
|
import {Button} from '@instructure/ui-buttons'
|
||||||
|
import {Spinner} from '@instructure/ui-spinner'
|
||||||
|
import doFetchApi from '@canvas/do-fetch-api-effect'
|
||||||
|
import useFetchApi from '@canvas/use-fetch-api-hook'
|
||||||
|
import I18n from 'i18n!release_notes'
|
||||||
|
|
||||||
|
import NotesTable from './NotesTable'
|
||||||
|
import CreateEditModal from './CreateEditModal'
|
||||||
|
|
||||||
|
function notesReducer(prevState, action) {
|
||||||
|
if (action.type === 'FETCH_LOADING') {
|
||||||
|
return {...prevState, loading: action.payload}
|
||||||
|
} else if (action.type === 'FETCH_META') {
|
||||||
|
return {...prevState, nextPage: action.payload.next}
|
||||||
|
} else if (action.type === 'FETCH_SUCCESS') {
|
||||||
|
const newNotes = [...prevState.notes]
|
||||||
|
action.payload.forEach(row => {
|
||||||
|
if (!prevState.notes.some(n => n.id === row.id)) {
|
||||||
|
newNotes.push(row)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {...prevState, notes: newNotes}
|
||||||
|
} else if (action.type === 'PUBLISHED_STATE') {
|
||||||
|
const newNotes = cloneDeep(prevState.notes)
|
||||||
|
const relevantNote = newNotes.find(n => n.id === action.payload.id)
|
||||||
|
relevantNote.published = action.payload.state
|
||||||
|
return {...prevState, notes: newNotes}
|
||||||
|
} else if (action.type === 'UPSERT_NOTE') {
|
||||||
|
const newNotes = [...prevState.notes]
|
||||||
|
const relevantNote = newNotes.findIndex(n => n.id === action.payload.id)
|
||||||
|
if (relevantNote >= 0) {
|
||||||
|
newNotes[relevantNote] = action.payload
|
||||||
|
} else {
|
||||||
|
newNotes.unshift(action.payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {...prevState, notes: newNotes}
|
||||||
|
} else if (action.type === 'REMOVE_NOTE') {
|
||||||
|
const newNotes = prevState.notes.filter(note => note.id !== action.payload.id)
|
||||||
|
return {...prevState, notes: newNotes}
|
||||||
|
}
|
||||||
|
return prevState
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReleaseNotesEdit({envs, langs}) {
|
||||||
|
const [state, dispatch] = useReducer(notesReducer, {
|
||||||
|
notes: [],
|
||||||
|
nextPage: null,
|
||||||
|
loading: true
|
||||||
|
})
|
||||||
|
const [page, setPage] = useState(null)
|
||||||
|
const [showDialog, setShowDialog] = useState(false)
|
||||||
|
const [currentNote, setCurrentNote] = useState(null)
|
||||||
|
|
||||||
|
const editNote = useCallback(note => {
|
||||||
|
setCurrentNote(note)
|
||||||
|
setShowDialog(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createNote = useCallback(() => {
|
||||||
|
setCurrentNote(null)
|
||||||
|
setShowDialog(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useFetchApi({
|
||||||
|
path: '/api/v1/release_notes',
|
||||||
|
success: useCallback(response => {
|
||||||
|
dispatch({type: 'FETCH_SUCCESS', payload: response})
|
||||||
|
}, []),
|
||||||
|
meta: useCallback(({link}) => {
|
||||||
|
dispatch({type: 'FETCH_META', payload: link})
|
||||||
|
}, []),
|
||||||
|
error: useCallback(error => dispatch({type: 'FETCH_ERROR', payload: error}), []),
|
||||||
|
loading: useCallback(loading => dispatch({type: 'FETCH_LOADING', payload: loading}), []),
|
||||||
|
params: {
|
||||||
|
includes: ['langs'],
|
||||||
|
per_page: 20,
|
||||||
|
page
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const setPublished = useCallback(async (id, publishedState) => {
|
||||||
|
await doFetchApi({
|
||||||
|
path: `/api/v1/release_notes/${id}/published`,
|
||||||
|
method: publishedState ? 'PUT' : 'DELETE'
|
||||||
|
})
|
||||||
|
dispatch({
|
||||||
|
type: 'PUBLISHED_STATE',
|
||||||
|
payload: {
|
||||||
|
id,
|
||||||
|
state: publishedState
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const upsertNote = useCallback(async newNote => {
|
||||||
|
const note = await doFetchApi({
|
||||||
|
path: `/api/v1/release_notes${newNote.id ? `/${newNote.id}` : ''}`,
|
||||||
|
method: newNote.id ? 'PUT' : 'POST',
|
||||||
|
body: newNote
|
||||||
|
})
|
||||||
|
dispatch({type: 'UPSERT_NOTE', payload: note.json})
|
||||||
|
setShowDialog(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const deleteNote = useCallback(async id => {
|
||||||
|
await doFetchApi({
|
||||||
|
path: `/api/v1/release_notes/${id}`,
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
dispatch({type: 'REMOVE_NOTE', payload: {id}})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (state.loading) {
|
||||||
|
return <Spinner renderTitle={I18n.t('Loading')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={createNote} color="primary">
|
||||||
|
{I18n.t('New Note')}
|
||||||
|
</Button>
|
||||||
|
<NotesTable
|
||||||
|
notes={state.notes}
|
||||||
|
setPublished={setPublished}
|
||||||
|
editNote={editNote}
|
||||||
|
deleteNote={deleteNote}
|
||||||
|
/>
|
||||||
|
{state.nextPage ? (
|
||||||
|
<Button onClick={() => setPage(state.nextPage.page)}>{I18n.t('Load more')}</Button>
|
||||||
|
) : null}
|
||||||
|
<CreateEditModal
|
||||||
|
open={showDialog}
|
||||||
|
onClose={() => setShowDialog(false)}
|
||||||
|
currentNote={currentNote}
|
||||||
|
onSubmit={upsertNote}
|
||||||
|
envs={envs}
|
||||||
|
langs={langs}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2021 - 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 I18n from 'i18n!release_notes'
|
||||||
|
|
||||||
|
export const roles = [
|
||||||
|
{
|
||||||
|
id: 'user',
|
||||||
|
label: I18n.t('Everyone')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin',
|
||||||
|
label: I18n.t('Admins')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'teacher',
|
||||||
|
label: I18n.t('Teachers')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'student',
|
||||||
|
label: I18n.t('Students')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'observer',
|
||||||
|
label: I18n.t('Observers')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const rolesObject = Object.fromEntries(roles.map(r => [r.id, r.label]))
|
Loading…
Reference in New Issue