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_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 = []
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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") },
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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