From 2ec11fa7919946e135c027189a44750dfad4670a Mon Sep 17 00:00:00 2001 From: Tomas Kikutis Date: Mon, 3 Jun 2024 16:40:45 +0200 Subject: [PATCH] Custom editor3 blocks (#4532) --- e2e/client/playwright.config.ts | 2 + e2e/client/playwright/editor3.spec.ts | 84 ++- .../settings/content-profile.ts | 45 ++ e2e/client/playwright/utils/editor3.tsx | 9 + .../playwright/utils/tree-select-driver.ts | 50 ++ e2e/server/dump/records/README.md | 5 +- .../dump/records/custom-blocks.json.bz2 | Bin 0 -> 2413 bytes karma.conf.js | 4 +- package-lock.json | 34 +- package.json | 3 +- .../directives/RelatedItemsDirective.ts | 4 +- .../relations/services/RelationsService.ts | 4 +- scripts/apps/vocabularies/constants.ts | 13 +- .../controllers/VocabularyConfigController.ts | 5 +- .../controllers/VocabularyEditController.tsx | 120 ++++- scripts/apps/vocabularies/views/settings.html | 4 +- .../views/vocabulary-config-modal.html | 67 ++- .../vocabularies/views/vocabulary-config.html | 1 + .../components/ContentProfileFieldsConfig.tsx | 2 + .../get-content-profiles-form-config.tsx | 4 + scripts/apps/workspace/content/constants.ts | 4 + .../views/FormattingOptionsMultiSelect.tsx | 38 +- .../content/views/profile-settings.html | 10 +- scripts/core/editor3/actions/custom-block.tsx | 4 + scripts/core/editor3/actions/editor3.tsx | 11 +- scripts/core/editor3/actions/index.tsx | 1 + scripts/core/editor3/actions/table.tsx | 4 +- scripts/core/editor3/actions/toolbar.tsx | 20 +- .../components/BaseUnstyledComponent.tsx | 10 +- .../editor3/components/Editor3Component.tsx | 31 +- .../article-embed/article-embed.tsx | 4 +- .../core/editor3/components/blockRenderer.tsx | 74 ++- .../core/editor3/components/custom-block.scss | 46 ++ .../core/editor3/components/custom-block.tsx | 53 ++ .../editor3/components/embeds/EmbedBlock.tsx | 19 +- .../editor3/components/links/LinkInput.tsx | 11 +- .../editor3/components/links/LinkToolbar.tsx | 11 +- .../editor3/components/media/MediaBlock.tsx | 41 +- .../dragable-editor3-block-with-labels.tsx | 72 +++ .../media/dragable-editor3-block.tsx | 6 +- .../core/editor3/components/media/index.ts | 1 - .../multi-line-quote/MultiLineQuote.tsx | 36 +- .../editor3/components/tables/TableBlock.tsx | 106 +++- .../editor3/components/tests/editor3.spec.tsx | 6 +- .../editor3/components/tests/embeds.spec.tsx | 2 - .../editor3/components/tests/media.spec.tsx | 7 + .../editor3/components/tests/tables.spec.tsx | 2 + .../toolbar/MultiLineQuoteControls.tsx | 124 ----- .../components/toolbar/StyleButton.tsx | 12 +- .../components/toolbar/TableControls.tsx | 236 ++++++--- .../core/editor3/components/toolbar/index.tsx | 490 +++++++++++------- scripts/core/editor3/constants.ts | 10 + scripts/core/editor3/directive.tsx | 6 +- .../get-formatting-options-for-table.tsx | 46 ++ scripts/core/editor3/helpers/table.ts | 51 +- .../editor3/html/to-html/AtomicBlockParser.ts | 46 +- .../core/editor3/reducers/custom-block.tsx | 27 + scripts/core/editor3/reducers/editor3.tsx | 5 +- scripts/core/editor3/reducers/index.tsx | 6 +- .../editor3/reducers/multi-line-quote.tsx | 82 +-- scripts/core/editor3/reducers/table.tsx | 199 ++++--- scripts/core/editor3/reducers/toolbar.tsx | 38 +- scripts/core/editor3/store/index.ts | 6 +- scripts/core/editor3/styles.scss | 10 +- scripts/core/helpers/utils.tsx | 4 + scripts/core/superdesk-api.d.ts | 117 +++-- .../generic-list-page-item-view-edit.tsx | 5 +- .../components/ListPage/generic-list-page.tsx | 10 +- .../input-types/select_multiple_values.tsx | 16 +- .../generic-form/tests/generic-form.spec.tsx | 4 +- scripts/core/ui/ui.ts | 2 +- styles/sass/app.scss | 2 + 72 files changed, 1756 insertions(+), 888 deletions(-) create mode 100644 e2e/client/playwright/page-object-models/settings/content-profile.ts create mode 100644 e2e/client/playwright/utils/tree-select-driver.ts create mode 100644 e2e/server/dump/records/custom-blocks.json.bz2 create mode 100644 scripts/core/editor3/actions/custom-block.tsx create mode 100644 scripts/core/editor3/components/custom-block.scss create mode 100644 scripts/core/editor3/components/custom-block.tsx create mode 100644 scripts/core/editor3/components/media/dragable-editor3-block-with-labels.tsx delete mode 100644 scripts/core/editor3/components/media/index.ts delete mode 100644 scripts/core/editor3/components/toolbar/MultiLineQuoteControls.tsx create mode 100644 scripts/core/editor3/get-formatting-options-for-table.tsx create mode 100644 scripts/core/editor3/reducers/custom-block.tsx diff --git a/e2e/client/playwright.config.ts b/e2e/client/playwright.config.ts index 5fcb57479b..1f7786e445 100644 --- a/e2e/client/playwright.config.ts +++ b/e2e/client/playwright.config.ts @@ -30,6 +30,8 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', + + screenshot: 'only-on-failure', }, /* Configure projects for major browsers */ diff --git a/e2e/client/playwright/editor3.spec.ts b/e2e/client/playwright/editor3.spec.ts index ffbe8fe66f..9ecf26ff16 100644 --- a/e2e/client/playwright/editor3.spec.ts +++ b/e2e/client/playwright/editor3.spec.ts @@ -1,7 +1,8 @@ -import {test, expect} from '@playwright/test'; import {Monitoring} from './page-object-models/monitoring'; +import {test, expect} from '@playwright/test'; import {restoreDatabaseSnapshot, s} from './utils'; -import {getEditor3Paragraphs} from './utils/editor3'; +import {getEditor3FormattingOptions, getEditor3Paragraphs} from './utils/editor3'; +import {TreeSelectDriver} from './utils/tree-select-driver'; test('accepting a spelling suggestion', async ({page}) => { const monitoring = new Monitoring(page); @@ -214,3 +215,82 @@ test('tables maintaining cursor position when executing "redo" action', async ({ page.locator(s('authoring', 'authoring-field=body_html', 'table-block')).locator('[contenteditable]').first(), ).toHaveText('fobaro'); }); + +test('configuring a vocabulary for custom blocks', async ({page}) => { + await restoreDatabaseSnapshot(); + + await page.goto('/#/settings/vocabularies'); + + await page.locator(s('metadata-navigation')).getByRole('button', {name: 'Custom blocks'}).click(); + + await page.getByRole('button', {name: 'Add New'}).click(); + + // Input sample data + await page.locator(s('vocabulary-edit-content')).getByLabel('Id').fill('custom_blocks_2'); + + await page.locator(s('vocabulary-edit-content')).getByLabel('Name').fill('Custom blocks 2'); + + await new TreeSelectDriver( + page, + page.locator(s('vocabulary-edit-content', 'formatting-options')), + ).setValue(['h1']); + + await page.locator(s('vocabulary-edit-content', 'editor3')).getByRole('textbox').fill('test data'); + + + // Apply formatting option to sample text data + await page.locator(s('editor3')).getByText('test data').click(); + await page.locator(s('editor3', 'formatting-option=h1')).click(); + + // Save editor block + await page.locator(s('vocabulary-edit-footer')).getByRole('button', {name: 'Save'}).click(); + + await expect(page.locator(s('vocabulary-edit-content'))).not.toBeVisible(); // wait for saving to finish + + // Edit custom block + await page.locator(s('vocabulary-item=Custom blocks 2')).hover(); + await page.locator(s('vocabulary-item=Custom blocks 2', 'vocabulary-item--start-editing')).click(); + + // Check if formatting option, sample text data + await expect(page.locator(s('editor3', 'formatting-option=h1'))).toBeVisible(); + await expect(page.locator(s('editor3')).getByRole('textbox')).toHaveText('test data'); +}); + +test('adding a custom block inside editor3', async ({page}) => { + const monitoring = new Monitoring(page); + + await restoreDatabaseSnapshot({snapshotName: 'custom-blocks'}); + + await page.goto('/#/workspace/monitoring'); + + await monitoring.selectDeskOrWorkspace('Sports'); + + await page.locator( + s('monitoring-group=Sports / Working Stage', 'article-item=test sports story'), + ).dblclick(); + + await page.locator(s('authoring', 'authoring-field=body_html')).getByRole('textbox').click(); + + await page.locator( + s('authoring', 'authoring-field=body_html', 'toolbar'), + ).getByRole('button', {name: 'Custom block'}).click(); + + await page.locator(s('tree-menu-popover')) + .getByRole('button', {name: 'Custom Block 1'}) + .click(); + + await expect( + page.locator(s('authoring', 'authoring-field=body_html', 'custom-block')).getByRole('textbox').first(), + ).toHaveText('custom block 1 template content'); + + await page.locator( + s('authoring', 'authoring-field=body_html', 'custom-block'), + ).getByRole('textbox').click(); + + const result = await getEditor3FormattingOptions( + page.locator(s('authoring', 'authoring-field=body_html', 'editor3')), + ); + + expect(result).toEqual(['h2', 'italic', 'bold']); +}); + diff --git a/e2e/client/playwright/page-object-models/settings/content-profile.ts b/e2e/client/playwright/page-object-models/settings/content-profile.ts new file mode 100644 index 0000000000..597742e42b --- /dev/null +++ b/e2e/client/playwright/page-object-models/settings/content-profile.ts @@ -0,0 +1,45 @@ +import {Page, expect} from '@playwright/test'; +import {s} from '../../utils'; +import {TreeSelectDriver} from '../../utils/tree-select-driver'; + +interface IOptions { + profileName: string; + sectionName: string; + fieldName: string; + formattingOptionsToAdd: Array; +} + +export class ContentProfileSettings { + private page: Page; + + constructor(page: Page) { + this.page = page; + } + + public async addFormattingOptionToContentProfile(options: IOptions) { + await this.page.locator(s(`content-profile=${options.profileName}`)) + .getByRole('button', {name: 'Actions'}) + .click(); + await this.page.locator(s('content-profile-actions-popover')).getByRole('button', {name: 'Edit'}).click(); + + await this.page.locator(s('content-profile-edit-view')).getByRole('tab', {name: options.sectionName}).click(); + await this.page.locator(s('content-profile-edit-view', `field=${options.fieldName}`)).click(); + + await new TreeSelectDriver( + this.page, + this.page.locator(s('formatting-options-input')), + ).setValue(options.formattingOptionsToAdd); + + // this is required for validation. TODO: update DB snapshot to make current items already valid + await this.page.locator(s('generic-list-page', 'item-view-edit', 'gform-input--sdWidth')).selectOption('Full'); + + await this.page.locator(s('generic-list-page', 'item-view-edit', 'toolbar')) + .getByRole('button', {name: 'Apply'}) + .click(); + + await this.page.locator(s('content-profile-edit-view--footer')).getByRole('button', {name: 'Save'}).click(); + + // wait for saving to finish and modal to close + await expect(this.page.locator(s('content-profile-edit-view'))).not.toBeVisible(); + } +} diff --git a/e2e/client/playwright/utils/editor3.tsx b/e2e/client/playwright/utils/editor3.tsx index 39ab2697f4..889011c84c 100644 --- a/e2e/client/playwright/utils/editor3.tsx +++ b/e2e/client/playwright/utils/editor3.tsx @@ -1,4 +1,5 @@ import {Locator} from '@playwright/test'; +import {s} from '.'; export function getEditor3Paragraphs(field: Locator): Promise> { return field.locator('.DraftEditor-root') @@ -8,4 +9,12 @@ export function getEditor3Paragraphs(field: Locator): Promise> { .locator('> *') .allInnerTexts() .then((items) => items.filter((text) => text.trim().length > 0)); +} + +export function getEditor3FormattingOptions(field: Locator): Promise> { + return field.locator(s('toolbar', 'formatting-option')) + .all() + .then((elements) => Promise.all( + elements.map((element) => element.getAttribute('data-test-value')), + )); } \ No newline at end of file diff --git a/e2e/client/playwright/utils/tree-select-driver.ts b/e2e/client/playwright/utils/tree-select-driver.ts new file mode 100644 index 0000000000..7193c5ed57 --- /dev/null +++ b/e2e/client/playwright/utils/tree-select-driver.ts @@ -0,0 +1,50 @@ +import {Locator, Page} from '@playwright/test'; +import {s} from '.'; + +export class TreeSelectDriver { + private page: Page; + private element: Locator; + + constructor(page, element) { + this.page = page; + this.element = element; + + this.getValue = this.getValue.bind(this); + this.addValue = this.addValue.bind(this); + this.setValue = this.setValue.bind(this); + } + + public async getValue(): Promise> { + return this.element.locator(s('item')).all().then((buttons) => + Promise.all(buttons.map((button) => button.innerText())), + ); + } + + public async addValue(...options: Array | string>): Promise { + const setOptions = async (options: Array | string>) => { + for (const option of options) { + if (typeof option == 'string') { + await this.element.locator(s('open-popover')).click(); + await this.page.locator(s('tree-select-popover')) + .getByRole('button', {name: new RegExp(option, 'i')}) + .click(); + } else if (option != null) { + await setOptions(option); + } + } + }; + + await setOptions(options); + } + + public async setValue(...options: Array | string>) { + const removeButton = await this.element.getByRole('button', {name: 'remove-sign'}); + const removeButtonVisible = await removeButton.isVisible(); + + if (removeButtonVisible) { + await removeButton.click(); + } + + await this.addValue(...options); + } +} diff --git a/e2e/server/dump/records/README.md b/e2e/server/dump/records/README.md index b6ee9b62ae..19d57fd79e 100644 --- a/e2e/server/dump/records/README.md +++ b/e2e/server/dump/records/README.md @@ -1 +1,4 @@ -editor3-tables only adds tables formatting option to story content profile. It can be merged to main. The only reason it was added as a separate record is to avoid merge conflicts on open PRs. \ No newline at end of file +# Can easily be ported to main snapshot: + +* editor3-tables - adds tables formatting option to story content profile. +* custom-blocks - adds custom blocks to story/body_html content profile. Creates a new custom block vocabulary. \ No newline at end of file diff --git a/e2e/server/dump/records/custom-blocks.json.bz2 b/e2e/server/dump/records/custom-blocks.json.bz2 new file mode 100644 index 0000000000000000000000000000000000000000..51b5ba77a91e3e07b79f3dea7faff30ea0e23463 GIT binary patch literal 2413 zcmV-z36l0gT4*^jL0KkKS+*sK1ppcfUw|k8R0sd?Kkna(zyJU0U=6#nBvDYLlqo8tR6nsqhxb$^ zAJJkYPsM?QXZ@upr7&5d>nyUy zDq^Ni4-!d~Yb4emvsWtOet}l2Zp_h&eqo9hh{1BnADk#*n$_1AL@A!Er44bjjq@_~56t$|#QNZ4dB@|qv z^jQm(qRW?3szxiUT}!U`EZN197@1i`OwXms{Mg9HuC%39WVpc0rHzFx69v{%O_V8V z7AYnQNIT8D*~2i#ha@(ZJe7pw?VkzQOTl|m@gv&k?n_f@P?k8(Z%d_QsiG4 zecyL`)o0fz@~V|ml~A^V(6F=_!tpOY=vZM74RO3Gk2M%pv?~YP&EwMQj2u6s4{Ht> zzK6#b2s^&jDo!b5y`h9RhZv37Fo{pGPbm3a=Nug=+qziR*c_U-V(lVlB4pL4-|6vr zOuFiraLJ2<6|C2Df1v+Q3>iqAJ9|9*=Xhh%aj!S5-KMy=$2@NhFOH+l+r8ml7-p`I zCH;#n3VU_a3?2S-cAqKIH(kS9)oWE%ivH&BH;m_bFr2x&IxN4JA8Slr*x#hzA19Lu zIZU~5c1&-jamj-Qeym4sO3AUncJAaQ5=sK0d~7dRSE_ zo65Cl!M@SSlJwP-O&k=n3W@D>Q`+6-n zU2d4Ihgk2scQcWRnWl3nGIM+^f(a#<3 z(Ou>*Nb0iN7v#rhFJ6B&E~r^mTZ|D5QxQ-%aJ(q zX{J0eac1SSgfimr&5N$A*Pa>iV&k(mjB@9lOL;lx3?i7==H6!D;OEDa2958QS!>PJ z!KCMv+^1c5xv|03!=BEMMEWOY4AkuB4vx*Z@^hv3Un@ENjvN%ncSu!gx|=p%gR1XA z5r={@Xt1KO)R$@kQ=pTuk-yyPzZEI>sbwsxr1f-j zY5Y!Wnn^Bw@WFX0fXeYyg_kYGV(TA+McC+Pf|rqR$h?oi*6Oi(T}__@Zu(lcnnPwH&kx%d>DH6$@h!&ig!IT?<}!D|;Y z;92!LHBQ9AQbj6F=%rOkq;yJ)_Jy07GB&y{nK6g2Pe+}j`FcI9%)b0qU5tH_V#+?} z+Qss?IUZ(chwy$fDcCMOEN$>g^HGbxl_#(N9vdf@vAa`kp8{OgLyV8~Y+6apNLa}l znYompszRwv36zi4zH{VcQ#lG)Ni7jeJzkHYXTRLCwR(G_8!wgI@Fv9k^_@zTOsNPNuP8zA~Vg#To6 z%ff%*FFohs4iM3rMkyCsI6~3|(sEhUgNczS$YEiTW0Ida(KRX@A>7r#qXQk{J>^J; z%wlpWX*-@uP;hag7k!I2V!G%*bi0vMnsq)5*t@B9mvnJD(kaaxp}NvYaeswLE=YGe zL$REbk~TStOBub8qRX5LI1}hHh5U?@IBHr=P6fhXsAlYBg9hzOmu_T|EL|wN@^&sT zG#Nse%$P(hULw*xn) zf!ZuV&qUaygA&H68=0e1Vucy3S~84;EV!rC({#F&6`@G?w`;2yjSWVS)YYS@9o~u6J+S8Jki{B8k;SbTIeDC9mKnP) ziP%Fp!bT|ln6*2p5l?%e_L!O;5*JbrLc|`2$WA0GQbVaY3{?|^ijua6tH{8r8$bh(v_FeytWH(g7_%FoB` fYWr_Ynnt@CCZ%I#F00Ec|Ha&qP81|9iDE%OeQ1dn literal 0 HcmV?d00001 diff --git a/karma.conf.js b/karma.conf.js index 44f070b081..81270e970a 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -2,8 +2,6 @@ var path = require('path'); var grunt = require('grunt'); var makeConfig = require('./webpack.config.js'); -require('karma-spec-reporter'); - process.env.TZ = 'Europe/Prague'; module.exports = function(config) { @@ -56,7 +54,7 @@ module.exports = function(config) { }, // test results reporter to use - reporters: ['spec'], + reporters: ['dots'], // web server port port: 8080, diff --git a/package-lock.json b/package-lock.json index 22188a56b3..b007e32004 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9046,15 +9046,6 @@ } } }, - "karma-spec-reporter": { - "version": "0.0.36", - "resolved": "https://registry.npmjs.org/karma-spec-reporter/-/karma-spec-reporter-0.0.36.tgz", - "integrity": "sha512-11bvOl1x6ryKZph7kmbmMpbi8vsngEGxGOoeTlIcDaH3ab3j8aPJnZ+r+K/SS0sBSGy5VGkGYO2+hLct7hw/6w==", - "dev": true, - "requires": { - "colors": "1.4.0" - } - }, "karma-webpack": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/karma-webpack/-/karma-webpack-2.0.13.tgz", @@ -11962,6 +11953,11 @@ "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-2.0.0.tgz", "integrity": "sha512-GJTCeMSQU8UU1GqbsaDrg/IH+b/vSinJQl52NVpdJ7sShYLZA8Eq6jLF48Ye3N/dQloGrE07i7XsZvxQ9pNbqg==" }, + "primereact": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/primereact/-/primereact-6.6.0.tgz", + "integrity": "sha512-onoowjhlOz9Kcjna1DYEKWTGjKah4MjqZchm9E3BkcWjeXoCLv3F5QnIo8eVF9q+xymEK/6Oh7zHlfCJYyWwDw==" + }, "private": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", @@ -14805,11 +14801,12 @@ } }, "superdesk-ui-framework": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/superdesk-ui-framework/-/superdesk-ui-framework-3.1.3.tgz", - "integrity": "sha512-COqb+IPwqY4PE+ns2PIACp4cy1GPyZxanoJ/GUj1tHe3JW6FJWr6zkU5PV0tFU2p5JQcUI8SHHTCdJHgbER1ZQ==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/superdesk-ui-framework/-/superdesk-ui-framework-3.1.9.tgz", + "integrity": "sha512-mgBkdsv/mvG02WUNt+7szbw+V0xDK0BtG5nuZag/GuvHjJmTGW0s0OhuC/Q+/SGkwg/iR+C7DLYqUY4l+7TQXQ==", "requires": { "@popperjs/core": "^2.4.0", + "@superdesk/common": "0.0.28", "@superdesk/primereact": "^5.0.2-12", "@superdesk/react-resizable-panels": "0.0.39", "@types/enzyme-adapter-react-16": "^1.0.6", @@ -14827,6 +14824,19 @@ "react-scrollspy": "^3.4.3" }, "dependencies": { + "@superdesk/common": { + "version": "0.0.28", + "resolved": "https://registry.npmjs.org/@superdesk/common/-/common-0.0.28.tgz", + "integrity": "sha512-EhsYMm340r3FVrakH00lLvQbxVYYTzL61J5GXI3BI2xLN2dPI3N0AJEaMGqjbt0xUpUFxE3T08OtYvIC5koZvg==", + "requires": { + "date-fns": "2.7.0", + "lodash": "4.17.19", + "primereact": "^6.0.2", + "react": "16.9.0", + "react-dom": "16.9.0", + "react-sortable-hoc": "^1.11.0" + } + }, "@types/node": { "version": "14.18.63", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", diff --git a/package.json b/package.json index 57c8ec0ac9..d9ef6512b9 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "sass-loader": "6.0.6", "shortid": "2.2.8", "style-loader": "0.20.2", - "superdesk-ui-framework": "^3.1.3", + "superdesk-ui-framework": "^3.1.9", "ts-loader": "3.5.0", "typescript": "4.9.5", "uuid": "8.3.1", @@ -145,7 +145,6 @@ "karma-jasmine": "^1.1.1", "karma-ng-html2js-preprocessor": "^1.0.0", "karma-sourcemap-loader": "^0.3.7", - "karma-spec-reporter": "0.0.36", "karma-webpack": "^2.0.13", "react-addons-test-utils": "^15.6.0", "react-test-renderer": "^16.13.1", diff --git a/scripts/apps/relations/directives/RelatedItemsDirective.ts b/scripts/apps/relations/directives/RelatedItemsDirective.ts index ed4570a51d..0955be957a 100644 --- a/scripts/apps/relations/directives/RelatedItemsDirective.ts +++ b/scripts/apps/relations/directives/RelatedItemsDirective.ts @@ -1,6 +1,6 @@ import {gettext} from 'core/utils'; import {AuthoringWorkspaceService} from 'apps/authoring/authoring/services/AuthoringWorkspaceService'; -import {IArticle, IVocabulary, IRendition} from 'superdesk-api'; +import {IArticle, IRendition, IVocabularyRelatedContent, IVocabularyMedia} from 'superdesk-api'; import {IDirectiveScope} from 'types/Angular/DirectiveScope'; import {getAssociationsByFieldId} from '../../authoring/authoring/controllers/AssociationController'; import {getThumbnailForItem} from 'core/helpers/item'; @@ -14,7 +14,7 @@ interface IScope extends IDirectiveScope { relatedItemsNewButton: typeof RelatedItemCreateNewButton; onCreated: (items: Array) => void; gettext: (text: any, params?: any) => string; - field: IVocabulary; + field: IVocabularyMedia | IVocabularyRelatedContent; editable: boolean; item: IArticle; loading: boolean; diff --git a/scripts/apps/relations/services/RelationsService.ts b/scripts/apps/relations/services/RelationsService.ts index 55b92936b8..7394153e82 100644 --- a/scripts/apps/relations/services/RelationsService.ts +++ b/scripts/apps/relations/services/RelationsService.ts @@ -1,5 +1,5 @@ import {zipObject} from 'lodash'; -import {IArticle, IVocabulary} from 'superdesk-api'; +import {IArticle, IVocabularyMedia, IVocabularyRelatedContent} from 'superdesk-api'; import {isPublished, isIngested} from 'apps/archive/utils'; import {gettext} from 'core/utils'; @@ -79,7 +79,7 @@ export function RelationsService(api, $q) { })).then((values) => zipObject(relatedItemsKeys, values)); }; - this.itemHasAllowedStatus = function(item: IArticle, field: IVocabulary) { + this.itemHasAllowedStatus = function(item: IArticle, field: IVocabularyRelatedContent | IVocabularyMedia) { return validateWorkflow(item, field?.field_options?.allowed_workflows ?? {}).result; }; } diff --git a/scripts/apps/vocabularies/constants.ts b/scripts/apps/vocabularies/constants.ts index 7a8186e6e3..53ccb1886b 100644 --- a/scripts/apps/vocabularies/constants.ts +++ b/scripts/apps/vocabularies/constants.ts @@ -1,6 +1,17 @@ import {gettext} from 'core/utils'; -export function getMediaTypes() { +interface IMediaType { + GALLERY: { + id: 'media'; + label: string; + }; + RELATED_CONTENT: { + id: 'related_content'; + label: string; + } +} + +export function getMediaTypes(): IMediaType { return { GALLERY: { id: 'media', diff --git a/scripts/apps/vocabularies/controllers/VocabularyConfigController.ts b/scripts/apps/vocabularies/controllers/VocabularyConfigController.ts index af87a3740b..45f84d43e5 100644 --- a/scripts/apps/vocabularies/controllers/VocabularyConfigController.ts +++ b/scripts/apps/vocabularies/controllers/VocabularyConfigController.ts @@ -1,10 +1,11 @@ import {DEFAULT_SCHEMA, getVocabularySelectionTypes, getMediaTypeKeys, getMediaTypes} from '../constants'; -import {IVocabulary, IVocabularyItem, IVocabularyTag} from 'superdesk-api'; +import {IVocabulary, IVocabularyTag} from 'superdesk-api'; import {IDirectiveScope} from 'types/Angular/DirectiveScope'; import {remove, reduce} from 'lodash'; import {gettext, downloadFile} from 'core/utils'; import {showModal} from '@superdesk/common'; import {UploadConfig} from '../components/UploadConfigModal'; +import {EDITOR_BLOCK_FIELD_TYPE} from 'apps/workspace/content/constants'; function getOther() { return gettext('Other'); @@ -110,7 +111,7 @@ export function VocabularyConfigController($scope: IScope, $route, $routeParams, $scope.matchFieldTypeToTab = (tab, fieldType) => tab === 'vocabularies' && !fieldType || fieldType && (tab === 'text-fields' && fieldType === 'text' || - tab === 'custom-editor-blocks' && fieldType === 'editor-block' || + tab === 'custom-editor-blocks' && fieldType === EDITOR_BLOCK_FIELD_TYPE || tab === 'date-fields' && fieldType === 'date' || tab === 'urls-fields' && fieldType === 'urls' || tab === 'related-content-fields' && getMediaTypeKeys().includes(fieldType) || diff --git a/scripts/apps/vocabularies/controllers/VocabularyEditController.tsx b/scripts/apps/vocabularies/controllers/VocabularyEditController.tsx index ed0db373e0..578274ce86 100644 --- a/scripts/apps/vocabularies/controllers/VocabularyEditController.tsx +++ b/scripts/apps/vocabularies/controllers/VocabularyEditController.tsx @@ -4,10 +4,14 @@ import _ from 'lodash'; import {IVocabularySelectionTypes, getVocabularySelectionTypes, getMediaTypeKeys, getMediaTypes} from '../constants'; import {gettext} from 'core/utils'; import {getFields} from 'apps/fields'; -import {IVocabulary} from 'superdesk-api'; +import {IArticle, IVocabulary, RICH_FORMATTING_OPTION} from 'superdesk-api'; import {IScope as IScopeConfigController} from './VocabularyConfigController'; import {VocabularyItemsViewEdit} from '../components/VocabularyItemsViewEdit'; import {defaultAllowedWorkflows} from 'apps/relations/services/RelationsService'; +import {EDITOR_BLOCK_FIELD_TYPE} from 'apps/workspace/content/constants'; +import {getEditor3RichTextFormattingOptions} from 'apps/workspace/content/components/get-content-profiles-form-config'; +import {ContentState, convertToRaw} from 'draft-js'; +import {getFormattingOptionsForTableLikeBlocks} from 'core/editor3/get-formatting-options-for-table'; VocabularyEditController.$inject = [ '$scope', @@ -32,13 +36,24 @@ interface IScope extends IScopeConfigController { _errorUniqueness: boolean; errorMessage: string; save: () => void; + + // custom-editor-block props START + formattingOptionsOnChange?: (options: Array) => void; + editorBlockFormattingOptions?: Array<{value: [RICH_FORMATTING_OPTION, string]}>; + fakeItem?: Partial; + editorBlockFieldId?: string; + // custom-editor-block props END + + updateUI: () => void; + requestEditor3DirectivesToGenerateHtml: Array<() => void>; + handleTemplateValueChange: (value: string) => void; requireAllowedTypesSelection: () => void; addItem: () => void; cancel: () => void; model: any; schema: any; schemaFields: Array; - itemsValidation: { valid: boolean }; + itemsValidation: {valid: boolean}; customFieldTypes: Array<{id: string, label: string}>; setCustomFieldConfig: (config: any) => void; editForm: any; @@ -47,6 +62,9 @@ interface IScope extends IScopeConfigController { } const idRegex = '^[a-zA-Z0-9-_]+$'; +const editorBlockFieldId = 'editor_block_field'; + +type IFormattingOptionTuple = [notTranslatedOption: RICH_FORMATTING_OPTION, translatedOption: string]; export function VocabularyEditController( $scope: IScope, notify, api, metadata, cvSchema, relationsService, $timeout, @@ -61,16 +79,22 @@ export function VocabularyEditController( $scope.tab = tab; }; + $scope.requestEditor3DirectivesToGenerateHtml = []; + $scope.idRegex = idRegex; $scope.selectionTypes = getVocabularySelectionTypes(); - if ($scope.matchFieldTypeToTab('related-content-fields', $scope.vocabulary.field_type)) { + if ( + $scope.matchFieldTypeToTab('related-content-fields', $scope.vocabulary.field_type) + && $scope.vocabulary.field_type === 'related_content' + ) { + const vocab = $scope.vocabulary; + // Insert default allowed workflows - if ($scope.vocabulary.field_options == null) { - $scope.vocabulary.field_options = {allowed_workflows: defaultAllowedWorkflows}; - } else if ($scope.vocabulary.field_options.allowed_workflows == null) { - $scope.vocabulary.field_options.allowed_workflows = defaultAllowedWorkflows; - } + vocab.field_options = { + ...(vocab.field_options ?? {}), + allowed_workflows: defaultAllowedWorkflows, + }; } function onSuccess(result) { @@ -84,10 +108,9 @@ export function VocabularyEditController( function onError(response) { if (angular.isDefined(response.data._issues)) { if (angular.isDefined(response.data._issues['validator exception'])) { - notify.error(gettext('Error: ' + - response.data._issues['validator exception'])); + notify.error(gettext('Error: {{ message }}', {message: response.data._issues['validator exception']})); } else if (angular.isDefined(response.data._issues.error) && - response.data._issues.error.required_field) { + response.data._issues.error.required_field) { let params = response.data._issues.params; notify.error(gettext( @@ -111,6 +134,52 @@ export function VocabularyEditController( return true; } + if ($scope.vocabulary.field_type === EDITOR_BLOCK_FIELD_TYPE) { + const vocabulary = $scope.vocabulary; + + $scope.editorBlockFieldId = editorBlockFieldId; + + $scope.fakeItem = { + fields_meta: { + + /** + * Fake field, needed for compatibility with sdEditor3 directive + */ + [editorBlockFieldId]: { + draftjsState: vocabulary.field_options?.template != null + ? vocabulary.field_options.template + : [convertToRaw(ContentState.createFromText(''))], + }, + }, + }; + + $scope.editorBlockFormattingOptions = ((): Array<{value: IFormattingOptionTuple}> => { + const allFormattingOptionsTranslated = getEditor3RichTextFormattingOptions(); + + return getFormattingOptionsForTableLikeBlocks().map( + (option) => ({value: [option, allFormattingOptionsTranslated[option]]}), + ); + })(); + + $scope.formattingOptionsOnChange = function(options) { + if (vocabulary.field_options == null) { + vocabulary.field_options = {}; + } + + vocabulary.field_options.formatting_options = options; + + /** + * Apply current changes to item and save them to the field as html, + * so when formatting options are updated editor sill has the changes + */ + $scope.requestEditor3DirectivesToGenerateHtml.forEach((fn) => { + fn(); + }); + + $scope.updateUI(); + }; + } + /** * Save current edit modal contents on backend. */ @@ -120,6 +189,22 @@ export function VocabularyEditController( $scope.errorMessage = null; delete $scope.vocabulary['_deleted']; + if ($scope.vocabulary.field_type === EDITOR_BLOCK_FIELD_TYPE) { + $scope.requestEditor3DirectivesToGenerateHtml.forEach((fn) => { + fn(); + }); + $scope.vocabulary.field_options = $scope.vocabulary.field_options ?? {}; + + /** + * Formatting options are updated in formattingOptionsOnChange and + * don't need to be updated explicitly here again. + */ + $scope.vocabulary.field_options = { + ...$scope.vocabulary.field_options, + template: $scope.fakeItem.fields_meta?.[editorBlockFieldId].draftjsState, + }; + } + if ($scope.vocabulary._id === 'crop_sizes') { var activeItems = _.filter($scope.vocabulary.items, (o) => o.is_active); @@ -175,7 +260,7 @@ export function VocabularyEditController( return false; } - if ($scope.vocabulary.field_options == null || $scope.vocabulary.field_options.allowed_types == null) { + if ($scope.vocabulary.field_type !== 'related_content') { return true; } @@ -231,12 +316,16 @@ export function VocabularyEditController( label: fields[id].label, })); - $scope.setCustomFieldConfig = (config) => { - $scope.vocabulary.custom_field_config = config; + $scope.updateUI = () => { $scope.editForm.$setDirty(); $scope.$applyAsync(); }; + $scope.setCustomFieldConfig = (config) => { + $scope.vocabulary.custom_field_config = config; + $scope.updateUI(); + }; + let placeholderElement = null; // wait for the template to render to the placeholder element is available @@ -252,8 +341,7 @@ export function VocabularyEditController( schemaFields={$scope.schemaFields} newItemTemplate={{...$scope.model, is_active: true}} setDirty={() => { - $scope.editForm.$setDirty(); - $scope.$apply(); + $scope.updateUI(); }} setItemsValid={(valid) => { $scope.itemsValidation.valid = valid; diff --git a/scripts/apps/vocabularies/views/settings.html b/scripts/apps/vocabularies/views/settings.html index d0f0bf04bb..fc2c02d37e 100644 --- a/scripts/apps/vocabularies/views/settings.html +++ b/scripts/apps/vocabularies/views/settings.html @@ -4,13 +4,13 @@

Metadata management

-
+
-