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:
Jacob Burroughs 2021-04-21 15:26:07 -05:00
parent dd5cf04e4c
commit e6ca5a7208
16 changed files with 1274 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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