diff --git a/app/controllers/release_notes_controller.rb b/app/controllers/release_notes_controller.rb
new file mode 100644
index 00000000000..8277a5914f0
--- /dev/null
+++ b/app/controllers/release_notes_controller.rb
@@ -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 .
+
+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
diff --git a/app/models/account.rb b/app/models/account.rb
index fa16dae2a7d..a4931671898 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -1700,6 +1700,7 @@ class Account < ActiveRecord::Base
TAB_PLUGINS = 14
TAB_JOBS = 15
TAB_DEVELOPER_KEYS = 16
+ TAB_RELEASE_NOTES = 17
def external_tool_tabs(opts, user)
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_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_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)
else
tabs = []
diff --git a/app/models/release_note.rb b/app/models/release_note.rb
index 2b3c8572371..0e3ae46c71c 100644
--- a/app/models/release_note.rb
+++ b/app/models/release_note.rb
@@ -42,6 +42,8 @@
class ReleaseNote
include ActiveModel::Dirty
+ include ActiveModel::Serializers::JSON
+
attr_reader :id, :published, :created_at
define_attribute_methods :id, :show_ats, :target_roles, :published
@@ -60,6 +62,10 @@ class ReleaseNote
@created_at = Time.parse(ddb_item['CreatedAt']).utc
end
+ def attributes
+ {'id' => nil, 'show_ats' => nil, 'target_roles' => nil, 'published' => nil, 'langs' => nil}
+ end
+
def target_roles
@target_roles.freeze
end
@@ -215,6 +221,13 @@ class ReleaseNote
@langs[lang] = translations
end
+ def langs
+ load_all_langs
+ @langs
+ end
+
+ private
+
def load_all_langs
return if @all_langs_loaded
@@ -304,7 +317,7 @@ class ReleaseNote
scan_index_forward: false,
exclusive_start_key: start
)
-
+
pager.replace(load_raw_records(res.items, include_langs: include_langs))
pager.has_more! unless res.last_evaluated_key.nil?
pager
@@ -327,11 +340,15 @@ class ReleaseNote
def load_raw_records(records, include_langs: false)
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
end
+ def enabled?
+ !ddb_table_name.nil?
+ end
+
def settings
YAML.safe_load(Canvas::DynamicSettings.find(tree: :private)['release_notes.yml'] || '{}')
end
diff --git a/app/models/role_override.rb b/app/models/role_override.rb
index 4283e4a6fe8..ff6ebaec0b6 100644
--- a/app/models/role_override.rb
+++ b/app/models/role_override.rb
@@ -306,6 +306,12 @@ class RoleOverride < ActiveRecord::Base
:true_for => %w(AccountAdmin),
: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 => {
:label => lambda { t('Blueprint Courses (create / edit / associate / delete)') },
:label_v2 => lambda { t("Blueprint Courses - add / edit / associate / delete") },
diff --git a/config/routes.rb b/config/routes.rb
index 2130844692f..a78172af06a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -732,6 +732,8 @@ CanvasRails::Application.routes.draw do
get :statistics
end
resources :developer_keys, only: :index
+
+ get 'release_notes' => 'release_notes#manage', as: :release_notes_manage
end
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
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
get 'accounts/:account_id/rubrics', action: :index, as: :account_rubrics
get 'accounts/:account_id/rubrics/:id', action: :show
diff --git a/spec/controllers/release_notes_controller_spec.rb b/spec/controllers/release_notes_controller_spec.rb
new file mode 100644
index 00000000000..c29d9bee6a8
--- /dev/null
+++ b/spec/controllers/release_notes_controller_spec.rb
@@ -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 .
+#
+
+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
diff --git a/ui/features/release_notes_edit/index.js b/ui/features/release_notes_edit/index.js
new file mode 100644
index 00000000000..81c952f6728
--- /dev/null
+++ b/ui/features/release_notes_edit/index.js
@@ -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 .
+ */
+
+import React from 'react'
+import ReactDOM from 'react-dom'
+import ReleaseNotesEdit from './react'
+import ready from '@instructure/ready'
+
+ready(() => {
+ ReactDOM.render(
+ ,
+ document.getElementById('content')
+ )
+})
diff --git a/ui/features/release_notes_edit/react/CreateEditModal.js b/ui/features/release_notes_edit/react/CreateEditModal.js
new file mode 100644
index 00000000000..d8e2b3c31f8
--- /dev/null
+++ b/ui/features/release_notes_edit/react/CreateEditModal.js
@@ -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 .
+ */
+
+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 (
+ {
+ e.preventDefault()
+ onSubmit(state)
+ }}
+ size="fullscreen"
+ label={label}
+ >
+
+
+ {label}
+
+
+ {I18n.t('Dates')}}
+ layout="columns"
+ startAt="small"
+ vAlign="top"
+ width="auto"
+ >
+ {envs.map(env => {
+ return (
+
+ reducer({action: 'SET_RELEASE_DATE', payload: {env, value: newValue}})
+ }
+ value={state.show_ats[env]}
+ data-testid={`show_at_input-${env}`}
+ />
+ )
+ })}
+
+
+ reducer({action: 'SET_ATTR', payload: {key: 'target_roles', value: newValue}})
+ }
+ >
+ {roles.map(role => {
+ return (
+
+ {role.label}
+
+ )
+ })}
+
+ {langs.map(lang => {
+ const isRequired = lang === 'en'
+ return (
+
+ <>
+
+ reducer({action: 'SET_LANG_ATTR', payload: {lang, key: 'title', value: v}})
+ }
+ required={isRequired}
+ data-testid={`title_input-${lang}`}
+ />
+
+ )
+ })}
+
+
+
+
+
+
+
+ )
+}
+
+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
diff --git a/ui/features/release_notes_edit/react/NotesTable.js b/ui/features/release_notes_edit/react/NotesTable.js
new file mode 100644
index 00000000000..bbb7b471931
--- /dev/null
+++ b/ui/features/release_notes_edit/react/NotesTable.js
@@ -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 .
+ */
+
+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 (
+
+ )
+}
diff --git a/ui/features/release_notes_edit/react/NotesTableRow.js b/ui/features/release_notes_edit/react/NotesTableRow.js
new file mode 100644
index 00000000000..503f0fe61e2
--- /dev/null
+++ b/ui/features/release_notes_edit/react/NotesTableRow.js
@@ -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 .
+ */
+
+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 (
+
+ {note.langs.en.title}
+ {note.langs.en.description}
+ {note.target_roles.map(role => rolesObject[role]).join(', ')}
+
+ {Object.keys(note.langs)
+ .map(lang => formatLanguage.of(lang))
+ .join(', ')}
+
+
+ {note.langs.en.url}
+
+
+
+ {note.published ? : }
+
+
+
+
+
+
+ )
+}
+
+// Because instui requires that the children of the body element be a "Row" element...
+NotesTableRow.displayName = 'Row'
diff --git a/ui/features/release_notes_edit/react/__tests__/CreateEditModal.test.js b/ui/features/release_notes_edit/react/__tests__/CreateEditModal.test.js
new file mode 100644
index 00000000000..dfaa80ba7b5
--- /dev/null
+++ b/ui/features/release_notes_edit/react/__tests__/CreateEditModal.test.js
@@ -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 .
+ */
+
+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(
+ {}}
+ 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(
+ {}}
+ 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(
+ {}}
+ 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({})
+ })
+})
diff --git a/ui/features/release_notes_edit/react/__tests__/NotesTable.test.js b/ui/features/release_notes_edit/react/__tests__/NotesTable.test.js
new file mode 100644
index 00000000000..ccd9f074f40
--- /dev/null
+++ b/ui/features/release_notes_edit/react/__tests__/NotesTable.test.js
@@ -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 .
+ */
+
+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()
+ expect(getByText(exampleNotes[0].langs.en.title)).toBeInTheDocument()
+ expect(getByText(exampleNotes[1].langs.en.title)).toBeInTheDocument()
+ })
+})
diff --git a/ui/features/release_notes_edit/react/__tests__/NotesTableRow.test.js b/ui/features/release_notes_edit/react/__tests__/NotesTableRow.test.js
new file mode 100644
index 00000000000..d6e241b777d
--- /dev/null
+++ b/ui/features/release_notes_edit/react/__tests__/NotesTableRow.test.js
@@ -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 .
+ */
+
+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
wrapper or validateDOMNesting gets angry
+describe('release notes row', () => {
+ it('renders the english attributes', () => {
+ const {getByText} = render(
+
+
+
+
+
+ )
+ 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(
+
+
+
+
+
+ )
+ expect(getByText('English')).toBeInTheDocument()
+ })
+
+ it('renders the list of languages comma separated for multiple languages', () => {
+ const {getByText} = render(
+
+
+
+
+
+ )
+ expect(getByText('English, Spanish')).toBeInTheDocument()
+ })
+
+ it('renders the list of roles for one role', () => {
+ const {getByText} = render(
+
+
+
+
+
+ )
+ expect(getByText('Everyone')).toBeInTheDocument()
+ })
+
+ it('renders the list of roles comma separated for multiple roles', () => {
+ const {getByText} = render(
+