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}`} + /> +