Add base block editor to storybook and page edit

flag=block_editor

This is an initial commit for the block editor work. It adds Editor.js,
a storybook example, and implements an editor change for wiki pages to
use the new component.

test plan:
  - Access the Block Editor component in Storybook.
  - Verify it renders and works correctly.
  - Turn the block editor flag on for an account.
  - Edit a wiki page
  - Verify the block editor is shown and works.

Change-Id: Icc57e7939b70fa141c2be4dde6cf34784ae995c6
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/331823
Reviewed-by: Jacob DeWar <jacob.dewar@instructure.com>
QA-Review: Jacob DeWar <jacob.dewar@instructure.com>
Migration-Review: Isaac Moore <isaac.moore@instructure.com>
Product-Review: Eric Saupe <eric.saupe@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
This commit is contained in:
Davis Hyer 2023-11-01 11:28:28 -06:00 committed by Eric Saupe
parent f801c44dba
commit c1c7c78118
18 changed files with 421 additions and 7 deletions

View File

@ -354,7 +354,9 @@ class WikiPagesApiController < ApplicationController
@wiki = @context.wiki
@page = @wiki.build_wiki_page(@current_user, initial_params)
if authorized_action(@page, @current_user, :create)
update_params = get_update_params(Set[:title, :body])
allowed_fields = Set[:title, :body]
allowed_fields << :block_editor_attributes if @context.account.feature_enabled?(:block_editor)
update_params = get_update_params(allowed_fields)
assign_todo_date
if !update_params.is_a?(Symbol) && @page.update(update_params) && process_front_page
log_asset_access(@page, "wiki", @wiki, "participate")
@ -434,6 +436,7 @@ class WikiPagesApiController < ApplicationController
if @page.new_record?
perform_update = true if authorized_action(@page, @current_user, [:create])
allowed_fields = Set[:title, :body]
allowed_fields << :block_editor_attributes if @context.account.feature_enabled?(:block_editor)
elsif authorized_action(@page, @current_user, [:update, :update_content])
perform_update = true
allowed_fields = Set[]
@ -639,7 +642,9 @@ class WikiPagesApiController < ApplicationController
def get_update_params(allowed_fields = Set[])
# normalize parameters
page_params = params[:wiki_page] ? params[:wiki_page].permit(*%w[title body notify_of_update published front_page editing_roles publish_at]) : {}
wiki_page_params = %w[title body notify_of_update published front_page editing_roles publish_at]
wiki_page_params += [block_editor_attributes: [:time, :version, { blocks: [:id, :type, { data: strong_anything }] }]] if @context.account.feature_enabled?(:block_editor)
page_params = params[:wiki_page] ? params[:wiki_page].permit(*wiki_page_params) : {}
if page_params.key?(:published)
published_value = page_params.delete(:published)
@ -665,6 +670,10 @@ class WikiPagesApiController < ApplicationController
end
change_front_page = !!@set_front_page
if page_params.key?(:block_editor_attributes)
page_params[:block_editor_attributes][:root_account_id] = @context.root_account_id
end
# check user permissions
rejected_fields = Set[]
if @wiki.grants_right?(@current_user, session, :update)
@ -683,6 +692,7 @@ class WikiPagesApiController < ApplicationController
unless @page.grants_right?(@current_user, session, :update)
allowed_fields << :body
allowed_fields << :block_editor_attributes if @context.account.feature_enabled?(:block_editor)
rejected_fields << :title if page_params.include?(:title) && page_params[:title] != @page.title
rejected_fields << :front_page if change_front_page && !@wiki.grants_right?(@current_user, session, :update)

View File

@ -174,7 +174,8 @@ class WikiPagesController < ApplicationController
wiki_page_menu_tools: external_tools_display_hashes(:wiki_page_menu),
wiki_index_menu_tools: external_tools_display_hashes(:wiki_index_menu),
DISPLAY_SHOW_ALL_LINK: tab_enabled?(context.class::TAB_PAGES, no_render: true) && !@k5_details_view,
CAN_SET_TODO_DATE: context.grants_any_right?(@current_user, session, :manage_content, :manage_course_content_edit)
CAN_SET_TODO_DATE: context.grants_any_right?(@current_user, session, :manage_content, :manage_course_content_edit),
BLOCK_EDITOR: context.account.feature_enabled?(:block_editor)
}
if Account.site_admin.feature_enabled?(:permanent_page_links)
title_availability_path = context.is_a?(Course) ? api_v1_course_page_title_availability_path : api_v1_group_page_title_availability_path

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
#
# Copyright (C) 2024 - 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 BlockEditor < ActiveRecord::Base
belongs_to :context, polymorphic: [:wiki_page]
alias_attribute :version, :editor_version
end

View File

@ -59,6 +59,8 @@ class WikiPage < ActiveRecord::Base
has_one :master_content_tag, class_name: "MasterCourses::MasterContentTag", inverse_of: :wiki_page
has_many :assignment_overrides, dependent: :destroy, inverse_of: :wiki_page
has_many :assignment_override_students, dependent: :destroy
has_one :block_editor, as: :context, dependent: :destroy
accepts_nested_attributes_for :block_editor, allow_destroy: true
acts_as_url :title, sync_url: true
validate :validate_front_page_visibility

View File

@ -240,3 +240,11 @@ observer_appointment_groups:
state: allowed_on
development:
state: allowed_on
block_editor:
applies_to: Account
state: hidden
display_name: Block Editor
description: |-
Enable the new block editor for the rich content editor.
beta: true

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
#
# Copyright (C) 2024 - 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 CreateBlockEditors < ActiveRecord::Migration[7.0]
tag :predeploy
def change
create_table :block_editors do |t|
t.references :root_account, null: false, foreign_key: { to_table: :accounts }, index: false
t.references :context, polymorphic: true, null: false, index: true
t.bigint :time
t.jsonb :blocks, default: [], null: false
t.string :editor_version
t.timestamps
end
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
#
# Copyright (C) 2024 - 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 AddReplicaIdentityToBlockEditors < ActiveRecord::Migration[7.0]
tag :predeploy
def change
add_replica_identity "BlockEditor", :root_account_id
end
end

View File

@ -130,6 +130,43 @@ describe WikiPagesApiController, type: :request do
create_wiki_page(@teacher, { title: "New Page", editing_roles: "public" }, 401)
expect(WikiPage.last).to be_nil
end
context "with the block editor" do
context "with the block editor feature flag on" do
before do
Account.default.enable_feature!(:block_editor)
end
it "succeeds" do
block_editor_attributes = {
time: Time.now.to_i,
blocks: [{ "data" => { "text" => "test" }, "id" => "R0iGYLKhw2", "type" => "paragraph" }],
version: "1.0"
}
create_wiki_page(@teacher, { title: "New Page", block_editor_attributes: })
expect(WikiPage.last.title).to eq "New Page"
expect(WikiPage.last.block_editor).to be_present
expect(WikiPage.last.block_editor.blocks).to eq([{ "data" => { "text" => "test" }, "id" => "R0iGYLKhw2", "type" => "paragraph" }])
end
end
context "with the block editor feature flag off" do
before do
Account.default.disable_feature!(:block_editor)
end
it "ignores the block_editor_attributes" do
block_editor_attributes = {
time: Time.now.to_i,
blocks: [{ "data" => { "text" => "test" }, "id" => "R0iGYLKhw2", "type" => "paragraph" }],
version: "1.0"
}
create_wiki_page(@teacher, { title: "New Page", block_editor_attributes: })
expect(WikiPage.last.title).to eq "New Page"
expect(WikiPage.last.block_editor).not_to be_present
end
end
end
end
context "with the user not having manage_wiki_create permission" do

View File

@ -45,6 +45,7 @@ const featureBundles: {
available_pronouns_list: () => import('./features/available_pronouns_list/index'),
blueprint_course_child: () => import('./features/blueprint_course_child/index'),
blueprint_course_master: () => import('./features/blueprint_course_master/index'),
block_editor: () => import('./features/block_editor/index'),
brand_configs: () => import('./features/brand_configs/index'),
calendar_appointment_group_edit: () => import('./features/calendar_appointment_group_edit/index'),
calendar: () => import('./features/calendar/index'),

View File

@ -0,0 +1,28 @@
// @ts-nocheck
/*
* Copyright (C) 2024 - 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 ready from '@instructure/ready'
import {BlockEditor} from '@canvas/block-editor'
ready(() => {
ReactDOM.render(<BlockEditor />, document.getElementById('block_editor'))
})

View File

@ -0,0 +1,6 @@
{
"name": "@canvas-features/block_editor",
"private": true,
"version": "0.1.0",
"owner": "LF"
}

View File

@ -0,0 +1,14 @@
{
"name": "@canvas/block-editor",
"private": true,
"version": "1.0.0",
"author": "neme",
"main": "./react/index.tsx",
"dependencies": {
"@editorjs/editorjs": "^2.28.2",
"@editorjs/header": "^2.7.0",
"@editorjs/nested-list": "^1.3.0",
"@editorjs/paragraph": "^2.11.3",
"@editorjs/quote": "^2.5.0"
}
}

View File

@ -0,0 +1,36 @@
/*
* Copyright (C) 2023 - 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 BlockEditor from './BlockEditor'
export default {
title: 'Examples/Shared/BlockEditor',
component: BlockEditor,
}
const Template = args => {
return (
<>
<BlockEditor {...args} />
</>
)
}
export const DefaultEditor = Template.bind({})

View File

@ -0,0 +1,91 @@
/*
* Copyright (C) 2023 - 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, {useRef} from 'react'
import {useScope as useI18nScope} from '@canvas/i18n'
import EditorJS from '@editorjs/editorjs'
import Header from '@editorjs/header'
import NestedList from '@editorjs/nested-list'
import Paragraph from '@editorjs/paragraph'
import Quote from '@editorjs/quote'
import {View} from '@instructure/ui-view'
const I18n = useI18nScope('block-editor')
export default function BlockEditor() {
const editor = useRef<EditorJS | null>(null)
React.useEffect(() => {
editor.current = new EditorJS({
holder: 'canvas-block-editor',
tools: {
header: {
class: Header,
inlineToolbar: true,
},
list: {
class: NestedList,
inlineToolbar: true,
config: {
defaultStyle: 'unordered',
},
},
paragraph: {
class: Paragraph,
inlineToolbar: false,
},
quote: {
class: Quote,
config: {
quotePlaceholder: 'Enter your quote here',
},
},
},
defaultBlock: 'paragraph',
placeholder: I18n.t('Press tab for more options'),
})
window.block_editor = editor.current
}, [])
return (
<View
as="span"
display="inline-block"
width="100%"
maxWidth="100%"
margin="small"
padding="small"
background="primary"
shadow="above"
borderRadius="large large none none"
>
<style>
{`
.ce-block__content {
max-width: 95%;
}
.ce-toolbar__content {
max-width: 95%;
}
`}
</style>
<div id="canvas-block-editor" data-testid="canvas-block-editor" />
</View>
)
}

View File

@ -0,0 +1,27 @@
/*
* Copyright (C) 2023 - 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 BlockEditor from './BlockEditor'
export {BlockEditor}
export default function renderBlockEditorApp(_, elt) {
ReactDOM.render(<BlockEditor />, elt)
}

View File

@ -19,6 +19,7 @@ import $ from 'jquery'
import React from 'react'
import ReactDOM from 'react-dom'
import RichContentEditor from '@canvas/rce/RichContentEditor'
import {BlockEditor} from '@canvas/block-editor'
import template from '../../jst/WikiPageEdit.handlebars'
import ValidatedFormView from '@canvas/forms/backbone/views/ValidatedFormView'
import WikiPageDeleteDialog from './WikiPageDeleteDialog'
@ -185,7 +186,11 @@ export default class WikiPageEditView extends ValidatedFormView {
})
}
RichContentEditor.loadNewEditor(this.$wikiPageBody, {focus: true, manageParent: true})
if (window.ENV.BLOCK_EDITOR) {
ReactDOM.render(<BlockEditor />, document.getElementById('block_editor'))
} else {
RichContentEditor.loadNewEditor(this.$wikiPageBody, {focus: true, manageParent: true})
}
this.checkUnsavedOnLeave = true
$(window).on('beforeunload', this.onUnload.bind(this))
@ -291,7 +296,7 @@ export default class WikiPageEditView extends ValidatedFormView {
)
}
submit(event) {
async submit(event) {
this.checkUnsavedOnLeave = false
if (this.reloadPending) {
if (
@ -309,6 +314,13 @@ export default class WikiPageEditView extends ValidatedFormView {
return
}
}
if (window.block_editor) {
let blockEditorData
await window.block_editor.save().then((outputData) => {
blockEditorData = outputData
})
this.blockEditorData = blockEditorData
}
if (this.reloadView != null) {
this.reloadView.stopPolling()
@ -349,7 +361,9 @@ export default class WikiPageEditView extends ValidatedFormView {
if (page_data.publish_at) {
page_data.publish_at = $.unfudgeDateForProfileTimezone(page_data.publish_at)
}
if (this.blockEditorData) {
page_data.block_editor_attributes = this.blockEditorData
}
if (this.shouldPublish) page_data.published = true
return page_data
}

View File

@ -17,7 +17,11 @@
{{{body}}}
{{else}}
<label for="wiki_page_body" class="hidden-readable" aria-hidden="true">{{#t}}Raw HTML Editor{{/t}}</label>
<textarea id="wiki_page_body" rows="20" cols="40" name="body" class="body" aria-hidden="true"{{#unless PAGE_RIGHTS.update}} autofocus{{/unless}}>{{convertApiUserContent body forEditing=1}}</textarea>
{{#if ENV.BLOCK_EDITOR}}
<div id="block_editor"></div>
{{else}}
<textarea id="wiki_page_body" rows="20" cols="40" name="body" class="body" aria-hidden="true"{{#unless PAGE_RIGHTS.update}} autofocus{{/unless}}>{{convertApiUserContent body forEditing=1}}</textarea>
{{/if}}
{{/if}}
{{#if CAN.EDIT_ROLES}}

View File

@ -1307,6 +1307,21 @@
exec-sh "^0.3.2"
minimist "^1.2.0"
"@codexteam/icons@^0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@codexteam/icons/-/icons-0.0.2.tgz#9183996a38b75a93506890373a015e3a2a369264"
integrity sha512-KdeKj3TwaTHqM3IXd5YjeJP39PBUZTb+dtHjGlf5+b0VgsxYD4qzsZkb11lzopZbAuDsHaZJmAYQ8LFligIT6Q==
"@codexteam/icons@^0.0.4":
version "0.0.4"
resolved "https://registry.yarnpkg.com/@codexteam/icons/-/icons-0.0.4.tgz#8b72dcd3f3a1b0d880bdceb2abebd74b46d3ae13"
integrity sha512-V8N/TY2TGyas4wLrPIFq7bcow68b3gu8DfDt1+rrHPtXxcexadKauRJL6eQgfG7Z0LCrN4boLRawR4S9gjIh/Q==
"@codexteam/icons@^0.0.5":
version "0.0.5"
resolved "https://registry.yarnpkg.com/@codexteam/icons/-/icons-0.0.5.tgz#d17f39b6a0497c6439f57dd42711817a3dd3679c"
integrity sha512-s6H2KXhLz2rgbMZSkRm8dsMJvyUNZsEjxobBEg9ztdrb1B2H3pEzY6iTwI4XUPJWJ3c3qRKwV4TrO3J5jUdoQA==
"@colors/colors@1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
@ -1324,6 +1339,39 @@
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==
"@editorjs/editorjs@^2.28.2":
version "2.29.0"
resolved "https://registry.yarnpkg.com/@editorjs/editorjs/-/editorjs-2.29.0.tgz#1c9846af19b2afab62a6bb3a815641721c0587f1"
integrity sha512-w2BVboSHokMBd/cAOZn0UU328o3gSZ8XUvFPA2e9+ciIGKILiRSPB70kkNdmhHkuNS3q2px+vdaIFaywBl7wGA==
"@editorjs/header@^2.7.0":
version "2.8.1"
resolved "https://registry.yarnpkg.com/@editorjs/header/-/header-2.8.1.tgz#ba16f43aaf461aa920c3594bdf0d854c4b5119b9"
integrity sha512-y0HVXRP7m2W617CWo3fsb5HhXmSLaRpb9GzFx0Vkp/HEm9Dz5YO1s8tC7R8JD3MskwoYh7V0hRFQt39io/r6hA==
dependencies:
"@codexteam/icons" "^0.0.5"
"@editorjs/nested-list@^1.3.0":
version "1.4.2"
resolved "https://registry.yarnpkg.com/@editorjs/nested-list/-/nested-list-1.4.2.tgz#2b47b9c3ee1ce11dec02eae0b176bd4107360847"
integrity sha512-qb1dAoJ+bihqmlR3822TC2GuIxEjTCLTZsZVWNces3uJIZ+W4019G3IJKBt/MOOgz4Evzad/RvUEKwPCPe6YOQ==
dependencies:
"@codexteam/icons" "^0.0.2"
"@editorjs/paragraph@^2.11.3":
version "2.11.3"
resolved "https://registry.yarnpkg.com/@editorjs/paragraph/-/paragraph-2.11.3.tgz#fb438de863179739f18de7d8851671a0d8447923"
integrity sha512-ON72lhvhgWzPrq4VXpHUeut9bsFeJgVATDeL850FVToOwYHKvdsNpfu0VgxEodhkXgzU/IGl4FzdqC2wd3AJUQ==
dependencies:
"@codexteam/icons" "^0.0.4"
"@editorjs/quote@^2.5.0":
version "2.6.0"
resolved "https://registry.yarnpkg.com/@editorjs/quote/-/quote-2.6.0.tgz#5ff02b5b3cac1fcea4157c31ac975e3acb3906a8"
integrity sha512-8DiCMBT4n4UDV5bgzvfRH26HmL6YWddGC4+twvjhR+PzX0gwrnY8nFifvro79EeSqxwRtFjjlGnu5I0VTfw5aQ==
dependencies:
"@codexteam/icons" "^0.0.5"
"@emotion/babel-plugin@^11.11.0":
version "11.11.0"
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz#c2d872b6a7767a9d176d007f5b31f7d504bb5d6c"