diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 8ec3db2e..c80b3ccc 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -2,9 +2,13 @@ name: Static analysis on: push: - branches: [ master ] + branches: + - '*' + - "!i10n_*" # Dont test localization branches pull_request: - branches: [ master ] + branches: + - '*' + - "!i10n_*" jobs: phpstan: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 561cffee..5a37dd50 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -94,7 +94,7 @@ jobs: run: yarn install - name: Build frontend - run: yarn dev + run: yarn build - name: Create DB run: php bin/console --env test doctrine:database:create --if-not-exists -n diff --git a/Dockerfile b/Dockerfile index 2f6d1fc2..b8d8fc1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,10 +26,10 @@ RUN docker-php-ext-enable opcache; \ echo 'realpath_cache_ttl=600'; \ } > /usr/local/etc/php/conf.d/symfony-recommended.ini -# Install yarn +# Install node and yarn RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list -RUN apt-get update && apt-get install -y yarn && apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/* +RUN curl -sL https://deb.nodesource.com/setup_18.x | bash - && apt-get update && apt-get install -y nodejs yarn && apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/* # Install composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer @@ -48,7 +48,6 @@ RUN a2enmod rewrite USER www-data RUN composer install -a --no-dev && composer clear-cache RUN yarn install && yarn build && yarn cache clean -RUN php bin/console --env=prod ckeditor:install --clear=skip # Use demo env to output logs to stdout ENV APP_ENV=demo diff --git a/assets/app.js b/assets/app.js deleted file mode 100644 index bb0a6aa1..00000000 --- a/assets/app.js +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Welcome to your app's main JavaScript file! - * - * We recommend including the built version of this JavaScript file - * (and its CSS file) in your base layout (base.html.twig). - */ - -// any CSS you import will output into a single css file (app.css in this case) -import './styles/app.css'; - -// start the Stimulus application -import './bootstrap'; diff --git a/assets/ckeditor/html_label.js b/assets/ckeditor/html_label.js new file mode 100644 index 00000000..0a16ec4b --- /dev/null +++ b/assets/ckeditor/html_label.js @@ -0,0 +1,220 @@ +/** + * @license Copyright (c) 2014-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor.js'; +import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment.js'; +import Autoformat from '@ckeditor/ckeditor5-autoformat/src/autoformat.js'; +import Base64UploadAdapter from '@ckeditor/ckeditor5-upload/src/adapters/base64uploadadapter.js'; +import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote.js'; +import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold.js'; +import Code from '@ckeditor/ckeditor5-basic-styles/src/code.js'; +import CodeBlock from '@ckeditor/ckeditor5-code-block/src/codeblock.js'; +import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials.js'; +import FindAndReplace from '@ckeditor/ckeditor5-find-and-replace/src/findandreplace.js'; +import FontBackgroundColor from '@ckeditor/ckeditor5-font/src/fontbackgroundcolor.js'; +import FontColor from '@ckeditor/ckeditor5-font/src/fontcolor.js'; +import FontFamily from '@ckeditor/ckeditor5-font/src/fontfamily.js'; +import FontSize from '@ckeditor/ckeditor5-font/src/fontsize.js'; +import GeneralHtmlSupport from '@ckeditor/ckeditor5-html-support/src/generalhtmlsupport.js'; +import Heading from '@ckeditor/ckeditor5-heading/src/heading.js'; +import Highlight from '@ckeditor/ckeditor5-highlight/src/highlight.js'; +import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline.js'; +import HtmlComment from '@ckeditor/ckeditor5-html-support/src/htmlcomment.js'; +import HtmlEmbed from '@ckeditor/ckeditor5-html-embed/src/htmlembed.js'; +import Image from '@ckeditor/ckeditor5-image/src/image.js'; +import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize.js'; +import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle.js'; +import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar.js'; +import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload.js'; +import Indent from '@ckeditor/ckeditor5-indent/src/indent.js'; +import IndentBlock from '@ckeditor/ckeditor5-indent/src/indentblock.js'; +import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic.js'; +import Link from '@ckeditor/ckeditor5-link/src/link.js'; +import LinkImage from '@ckeditor/ckeditor5-link/src/linkimage.js'; +import List from '@ckeditor/ckeditor5-list/src/list.js'; +import ListProperties from '@ckeditor/ckeditor5-list/src/listproperties.js'; +import Markdown from '@ckeditor/ckeditor5-markdown-gfm/src/markdown.js'; +import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed.js'; +import MediaEmbedToolbar from '@ckeditor/ckeditor5-media-embed/src/mediaembedtoolbar.js'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; +import PasteFromOffice from '@ckeditor/ckeditor5-paste-from-office/src/pastefromoffice.js'; +import RemoveFormat from '@ckeditor/ckeditor5-remove-format/src/removeformat.js'; +import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting.js'; +import SpecialCharacters from '@ckeditor/ckeditor5-special-characters/src/specialcharacters.js'; +import SpecialCharactersArrows from '@ckeditor/ckeditor5-special-characters/src/specialcharactersarrows.js'; +import SpecialCharactersCurrency from '@ckeditor/ckeditor5-special-characters/src/specialcharacterscurrency.js'; +import SpecialCharactersEssentials from '@ckeditor/ckeditor5-special-characters/src/specialcharactersessentials.js'; +import SpecialCharactersLatin from '@ckeditor/ckeditor5-special-characters/src/specialcharacterslatin.js'; +import SpecialCharactersMathematical from '@ckeditor/ckeditor5-special-characters/src/specialcharactersmathematical.js'; +import SpecialCharactersText from '@ckeditor/ckeditor5-special-characters/src/specialcharacterstext.js'; +import Strikethrough from '@ckeditor/ckeditor5-basic-styles/src/strikethrough.js'; +import Subscript from '@ckeditor/ckeditor5-basic-styles/src/subscript.js'; +import Superscript from '@ckeditor/ckeditor5-basic-styles/src/superscript.js'; +import Table from '@ckeditor/ckeditor5-table/src/table.js'; +import TableCaption from '@ckeditor/ckeditor5-table/src/tablecaption.js'; +import TableCellProperties from '@ckeditor/ckeditor5-table/src/tablecellproperties'; +import TableColumnResize from '@ckeditor/ckeditor5-table/src/tablecolumnresize.js'; +import TableProperties from '@ckeditor/ckeditor5-table/src/tableproperties'; +import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar.js'; +import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline.js'; +import WordCount from '@ckeditor/ckeditor5-word-count/src/wordcount.js'; +import EditorWatchdog from '@ckeditor/ckeditor5-watchdog/src/editorwatchdog.js'; +import PartDBLabel from "./plugins/PartDBLabel/PartDBLabel"; + +class Editor extends ClassicEditor {} + +// Plugins to include in the build. +Editor.builtinPlugins = [ + Alignment, + Autoformat, + Base64UploadAdapter, + BlockQuote, + Bold, + Code, + CodeBlock, + Essentials, + FindAndReplace, + FontBackgroundColor, + FontColor, + FontFamily, + FontSize, + GeneralHtmlSupport, + Heading, + Highlight, + HorizontalLine, + HtmlComment, + HtmlEmbed, + Image, + ImageResize, + ImageStyle, + ImageToolbar, + ImageUpload, + Indent, + IndentBlock, + Italic, + Link, + LinkImage, + List, + ListProperties, + MediaEmbed, + MediaEmbedToolbar, + Paragraph, + PasteFromOffice, + RemoveFormat, + SourceEditing, + SpecialCharacters, + SpecialCharactersArrows, + SpecialCharactersCurrency, + SpecialCharactersEssentials, + SpecialCharactersLatin, + SpecialCharactersMathematical, + SpecialCharactersText, + Strikethrough, + Subscript, + Superscript, + Table, + TableCaption, + TableCellProperties, + TableColumnResize, + TableProperties, + TableToolbar, + Underline, + WordCount, + + PartDBLabel +]; + +// Editor configuration. +Editor.defaultConfig = { + toolbar: { + items: [ + 'heading', + 'alignment', + '|', + 'bold', + 'italic', + 'underline', + 'strikethrough', + 'subscript', + 'superscript', + 'removeFormat', + 'highlight', + '|', + 'fontBackgroundColor', + 'fontColor', + 'fontSize', + '|', + 'fontFamily', + 'link', + 'bulletedList', + 'numberedList', + 'outdent', + 'indent', + '|', + 'specialCharacters', + 'horizontalLine', + '|', + 'imageUpload', + 'blockQuote', + 'insertTable', + 'mediaEmbed', + 'code', + 'codeBlock', + 'htmlEmbed', + '|', + 'undo', + 'redo', + 'findAndReplace', + 'sourceEditing', + '|', + 'partdb_label', + ], + shouldNotGroupWhenFull: true + }, + language: 'en', + fontFamily: { + options: [ + 'default', + 'DejaVu Sans Mono, monospace', + 'DejaVu Sans, sans-serif', + 'DejaVu Serif, serif', + 'Helvetica, Arial, sans-serif', + 'Times New Roman, Times, serif', + 'Courier New, Courier, monospace' + ], + supportAllValues: true + }, + 'fontSize': { + options: [ + 8, + 11, + 13, + 'default', + 17, + 19, + 21, + ], + supportAllValues: true + }, + image: { + toolbar: [ + 'imageTextAlternative', + 'imageStyle:inline', + 'imageStyle:block', + 'imageStyle:side', + 'linkImage' + ] + }, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells', + 'tableCellProperties', + 'tableProperties' + ] + } +}; + +export default { Editor, EditorWatchdog }; diff --git a/assets/ckeditor/markdown_full.js b/assets/ckeditor/markdown_full.js new file mode 100644 index 00000000..784bd688 --- /dev/null +++ b/assets/ckeditor/markdown_full.js @@ -0,0 +1,194 @@ +/** + * @license Copyright (c) 2014-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor.js'; +import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment.js'; +import Autoformat from '@ckeditor/ckeditor5-autoformat/src/autoformat.js'; +import Base64UploadAdapter from '@ckeditor/ckeditor5-upload/src/adapters/base64uploadadapter.js'; +import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote.js'; +import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold.js'; +import Code from '@ckeditor/ckeditor5-basic-styles/src/code.js'; +import CodeBlock from '@ckeditor/ckeditor5-code-block/src/codeblock.js'; +import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials.js'; +import FindAndReplace from '@ckeditor/ckeditor5-find-and-replace/src/findandreplace.js'; +import FontBackgroundColor from '@ckeditor/ckeditor5-font/src/fontbackgroundcolor.js'; +import FontColor from '@ckeditor/ckeditor5-font/src/fontcolor.js'; +import FontFamily from '@ckeditor/ckeditor5-font/src/fontfamily.js'; +import FontSize from '@ckeditor/ckeditor5-font/src/fontsize.js'; +import GeneralHtmlSupport from '@ckeditor/ckeditor5-html-support/src/generalhtmlsupport.js'; +import Heading from '@ckeditor/ckeditor5-heading/src/heading.js'; +import Highlight from '@ckeditor/ckeditor5-highlight/src/highlight.js'; +import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline.js'; +import HtmlComment from '@ckeditor/ckeditor5-html-support/src/htmlcomment.js'; +import HtmlEmbed from '@ckeditor/ckeditor5-html-embed/src/htmlembed.js'; +import Image from '@ckeditor/ckeditor5-image/src/image.js'; +import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize.js'; +import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle.js'; +import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar.js'; +import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload.js'; +import Indent from '@ckeditor/ckeditor5-indent/src/indent.js'; +import IndentBlock from '@ckeditor/ckeditor5-indent/src/indentblock.js'; +import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic.js'; +import Link from '@ckeditor/ckeditor5-link/src/link.js'; +import LinkImage from '@ckeditor/ckeditor5-link/src/linkimage.js'; +import List from '@ckeditor/ckeditor5-list/src/list.js'; +import ListProperties from '@ckeditor/ckeditor5-list/src/listproperties.js'; +import Markdown from '@ckeditor/ckeditor5-markdown-gfm/src/markdown.js'; +import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed.js'; +import MediaEmbedToolbar from '@ckeditor/ckeditor5-media-embed/src/mediaembedtoolbar.js'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; +import PasteFromOffice from '@ckeditor/ckeditor5-paste-from-office/src/pastefromoffice.js'; +import RemoveFormat from '@ckeditor/ckeditor5-remove-format/src/removeformat.js'; +import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting.js'; +import SpecialCharacters from '@ckeditor/ckeditor5-special-characters/src/specialcharacters.js'; +import SpecialCharactersArrows from '@ckeditor/ckeditor5-special-characters/src/specialcharactersarrows.js'; +import SpecialCharactersCurrency from '@ckeditor/ckeditor5-special-characters/src/specialcharacterscurrency.js'; +import SpecialCharactersEssentials from '@ckeditor/ckeditor5-special-characters/src/specialcharactersessentials.js'; +import SpecialCharactersLatin from '@ckeditor/ckeditor5-special-characters/src/specialcharacterslatin.js'; +import SpecialCharactersMathematical from '@ckeditor/ckeditor5-special-characters/src/specialcharactersmathematical.js'; +import SpecialCharactersText from '@ckeditor/ckeditor5-special-characters/src/specialcharacterstext.js'; +import Strikethrough from '@ckeditor/ckeditor5-basic-styles/src/strikethrough.js'; +import Subscript from '@ckeditor/ckeditor5-basic-styles/src/subscript.js'; +import Superscript from '@ckeditor/ckeditor5-basic-styles/src/superscript.js'; +import Table from '@ckeditor/ckeditor5-table/src/table.js'; +import TableCaption from '@ckeditor/ckeditor5-table/src/tablecaption.js'; +import TableCellProperties from '@ckeditor/ckeditor5-table/src/tablecellproperties'; +import TableColumnResize from '@ckeditor/ckeditor5-table/src/tablecolumnresize.js'; +import TableProperties from '@ckeditor/ckeditor5-table/src/tableproperties'; +import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar.js'; +import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline.js'; +import WordCount from '@ckeditor/ckeditor5-word-count/src/wordcount.js'; +import EditorWatchdog from '@ckeditor/ckeditor5-watchdog/src/editorwatchdog.js'; +import TodoList from '@ckeditor/ckeditor5-list/src/todolist'; + +import ExtendedMarkdown from "./plugins/extendedMarkdown.js"; +import SpecialCharactersEmoji from "./plugins/special_characters_emoji"; + +class Editor extends ClassicEditor {} + +// Plugins to include in the build. +Editor.builtinPlugins = [ + Autoformat, + Base64UploadAdapter, + BlockQuote, + Bold, + Code, + CodeBlock, + Essentials, + FindAndReplace, + FontBackgroundColor, + FontColor, + FontSize, + GeneralHtmlSupport, + Heading, + Highlight, + HorizontalLine, + Image, + ImageResize, + ImageStyle, + ImageToolbar, + ImageUpload, + Indent, + IndentBlock, + Italic, + Link, + LinkImage, + List, + //MediaEmbed, + //MediaEmbedToolbar, + Paragraph, + PasteFromOffice, + RemoveFormat, + SourceEditing, + SpecialCharacters, + SpecialCharactersArrows, + SpecialCharactersCurrency, + SpecialCharactersEssentials, + SpecialCharactersLatin, + SpecialCharactersMathematical, + SpecialCharactersText, + Strikethrough, + Subscript, + Superscript, + Table, + TableProperties, + TableToolbar, + Underline, + TodoList, + + //Our own extensions + ExtendedMarkdown, + SpecialCharactersEmoji +]; + +// Editor configuration. +Editor.defaultConfig = { + toolbar: { + items: [ + 'heading', + '|', + 'bold', + 'italic', + 'underline', + 'strikethrough', + 'subscript', + 'superscript', + 'removeFormat', + 'highlight', + '|', + 'fontBackgroundColor', + 'fontColor', + 'fontSize', + '|', + 'link', + 'bulletedList', + 'numberedList', + 'outdent', + 'indent', + '|', + 'specialCharacters', + 'horizontalLine', + '|', + 'imageUpload', + 'blockQuote', + 'insertTable', + //'mediaEmbed', + 'code', + 'codeBlock', + 'todoList', + '|', + 'undo', + 'redo', + 'findAndReplace', + 'sourceEditing', + ], + shouldNotGroupWhenFull: true + }, + language: 'en', + image: { + toolbar: [ + 'imageTextAlternative', + 'imageStyle:inline', + 'imageStyle:block', + 'imageStyle:side', + 'linkImage' + ] + }, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells', + 'tableProperties' + ] + }, + list: { + properties: { + styles: false, + + } + } +}; + +export default { Editor, EditorWatchdog }; diff --git a/assets/ckeditor/markdown_single_line.js b/assets/ckeditor/markdown_single_line.js new file mode 100644 index 00000000..f7e91aa9 --- /dev/null +++ b/assets/ckeditor/markdown_single_line.js @@ -0,0 +1,94 @@ +/** + * @license Copyright (c) 2014-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor.js'; +import Autoformat from '@ckeditor/ckeditor5-autoformat/src/autoformat.js'; +import AutoLink from '@ckeditor/ckeditor5-link/src/autolink.js'; +import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold.js'; +import Code from '@ckeditor/ckeditor5-basic-styles/src/code.js'; +import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials.js'; +import FindAndReplace from '@ckeditor/ckeditor5-find-and-replace/src/findandreplace.js'; +import Highlight from '@ckeditor/ckeditor5-highlight/src/highlight.js'; +import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic.js'; +import Link from '@ckeditor/ckeditor5-link/src/link.js'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; +import RemoveFormat from '@ckeditor/ckeditor5-remove-format/src/removeformat.js'; +import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting.js'; +import SpecialCharacters from '@ckeditor/ckeditor5-special-characters/src/specialcharacters.js'; +import SpecialCharactersArrows from '@ckeditor/ckeditor5-special-characters/src/specialcharactersarrows.js'; +import SpecialCharactersCurrency from '@ckeditor/ckeditor5-special-characters/src/specialcharacterscurrency.js'; +import SpecialCharactersEssentials from '@ckeditor/ckeditor5-special-characters/src/specialcharactersessentials.js'; +import SpecialCharactersLatin from '@ckeditor/ckeditor5-special-characters/src/specialcharacterslatin.js'; +import SpecialCharactersMathematical from '@ckeditor/ckeditor5-special-characters/src/specialcharactersmathematical.js'; +import SpecialCharactersText from '@ckeditor/ckeditor5-special-characters/src/specialcharacterstext.js'; +import Strikethrough from '@ckeditor/ckeditor5-basic-styles/src/strikethrough.js'; +import Subscript from '@ckeditor/ckeditor5-basic-styles/src/subscript.js'; +import Superscript from '@ckeditor/ckeditor5-basic-styles/src/superscript.js'; +import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline.js'; +import EditorWatchdog from '@ckeditor/ckeditor5-watchdog/src/editorwatchdog.js'; + +import ExtendedMarkdownInline from "./plugins/extendedMarkdownInline"; +import SingleLinePlugin from "./plugins/singleLine"; +import SpecialCharactersEmoji from "./plugins/special_characters_emoji"; + +class Editor extends ClassicEditor {} + +// Plugins to include in the build. +Editor.builtinPlugins = [ + Autoformat, + AutoLink, + Bold, + Code, + FindAndReplace, + Highlight, + Italic, + Link, + Paragraph, + RemoveFormat, + SourceEditing, + SpecialCharacters, + SpecialCharactersArrows, + SpecialCharactersCurrency, + SpecialCharactersEssentials, + SpecialCharactersLatin, + SpecialCharactersMathematical, + SpecialCharactersText, + Strikethrough, + Subscript, + Superscript, + Underline, + Essentials, + + ExtendedMarkdownInline, + SingleLinePlugin, + SpecialCharactersEmoji +]; + +// Editor configuration. +Editor.defaultConfig = { + toolbar: { + items: [ + 'bold', + 'italic', + 'underline', + 'strikethrough', + 'subscript', + 'superscript', + 'removeFormat', + 'highlight', + '|', + 'link', + 'code', + 'specialCharacters', + '|', + 'undo', + 'redo', + 'findAndReplace', + 'sourceEditing' + ] + }, + language: 'en' +}; + +export default { Editor, EditorWatchdog }; diff --git a/assets/ckeditor/plugins/PartDBLabel/PartDBLabel.css b/assets/ckeditor/plugins/PartDBLabel/PartDBLabel.css new file mode 100644 index 00000000..9171c357 --- /dev/null +++ b/assets/ckeditor/plugins/PartDBLabel/PartDBLabel.css @@ -0,0 +1,22 @@ +.cke_placeholder { + background: #ffff00; + padding: 4px 2px; + outline-offset: -2px; + line-height: 1em; + margin: 0 1px; +} + +.cke_placeholder::selection { + display: none; +} + +/*.cke_overflow_dropdown { + overflow: auto; + height: 40vh; +}*/ + +/** Make long editor dropdown panels scrollable */ +.ck-dropdown__panel { + overflow: auto; + max-height: 40vh; +} diff --git a/assets/ckeditor/plugins/PartDBLabel/PartDBLabel.js b/assets/ckeditor/plugins/PartDBLabel/PartDBLabel.js new file mode 100644 index 00000000..44f4a37e --- /dev/null +++ b/assets/ckeditor/plugins/PartDBLabel/PartDBLabel.js @@ -0,0 +1,16 @@ +import PartDBLabelUI from "./PartDBLabelUI"; +import PartDBLabelEditing from "./PartDBLabelEditing"; + +import "./PartDBLabel.css"; + +import Plugin from "@ckeditor/ckeditor5-core/src/plugin"; + +export default class PartDBLabel extends Plugin { + static get requires() { + return [PartDBLabelUI, PartDBLabelEditing]; + } + + static get pluginName() { + return 'PartDBLabel'; + } +} \ No newline at end of file diff --git a/assets/ckeditor/plugins/PartDBLabel/PartDBLabelCommand.js b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelCommand.js new file mode 100644 index 00000000..4fa30345 --- /dev/null +++ b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelCommand.js @@ -0,0 +1,31 @@ +import Command from '@ckeditor/ckeditor5-core/src/command'; + +export default class PartDBLabelCommand extends Command { + execute( { value } ) { + const editor = this.editor; + const selection = editor.model.document.selection; + + editor.model.change( writer => { + // Create a elment with the "name" attribute (and all the selection attributes)... + const placeholder = writer.createElement( 'partdb_label', { + ...Object.fromEntries( selection.getAttributes() ), + name: value + } ); + + // ... and insert it into the document. + editor.model.insertContent( placeholder ); + + // Put the selection on the inserted element. + writer.setSelection( placeholder, 'on' ); + } ); + } + + refresh() { + const model = this.editor.model; + const selection = model.document.selection; + + const isAllowed = model.schema.checkChild( selection.focus.parent, 'partdb_label' ); + + this.isEnabled = isAllowed; + } +} \ No newline at end of file diff --git a/assets/ckeditor/plugins/PartDBLabel/PartDBLabelEditing.js b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelEditing.js new file mode 100644 index 00000000..816c6f0c --- /dev/null +++ b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelEditing.js @@ -0,0 +1,86 @@ +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import PartDBLabelCommand from "./PartDBLabelCommand"; + +import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; + +export default class PartDBLabelEditing extends Plugin { + static get requires() { // ADDED + return [ Widget ]; + } + + init() { + + this._defineSchema(); + this._defineConverters(); + + this.editor.commands.add('partdb_label', new PartDBLabelCommand( this.editor ) ); + } + + _defineSchema() { + const schema = this.editor.model.schema; + + schema.register('partdb_label', { + // Allow wherever text is allowed: + allowWhere: '$text', + + // The placeholder will act as an inline node: + isInline: true, + + // The inline widget is self-contained so it cannot be split by the caret and can be selected: + isObject: true, + + allowAttributesOf: '$text', + + allowAttributes: [ 'name' ] + }); + } + + _defineConverters() { + const conversion = this.editor.conversion; + + conversion.for( 'upcast' ).elementToElement( { + view: { + name: 'span', + classes: [ 'cke_placeholder' ] + }, + model: ( viewElement, { writer: modelWriter } ) => { + // Extract the "name" from "{name}". + const name = viewElement.getChild( 0 ).data; + + return modelWriter.createElement( 'partdb_label', { name } ); + } + } ); + + conversion.for( 'editingDowncast' ).elementToElement( { + model: 'partdb_label', + view: ( modelItem, { writer: viewWriter } ) => { + const widgetElement = createPlaceholderView( modelItem, viewWriter ); + + // Enable widget handling on a placeholder element inside the editing view. + return toWidget( widgetElement, viewWriter ); + } + } ); + + conversion.for( 'dataDowncast' ).elementToElement( { + model: 'partdb_label', + view: ( modelItem, { writer: viewWriter } ) => createPlaceholderView( modelItem, viewWriter ) + } ); + + // Helper method for both downcast converters. + function createPlaceholderView( modelItem, viewWriter ) { + const name = modelItem.getAttribute( 'name' ); + + const placeholderView = viewWriter.createContainerElement( 'span', { + class: 'cke_placeholder' + } ); + + // Insert the placeholder name (as a text). + const innerText = viewWriter.createText( name ); + viewWriter.insert( viewWriter.createPositionAt( placeholderView, 0 ), innerText ); + + return placeholderView; + } + } + +} \ No newline at end of file diff --git a/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js new file mode 100644 index 00000000..b33f7f39 --- /dev/null +++ b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js @@ -0,0 +1,149 @@ +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; + +require('./lang/de.js'); + +import { addListToDropdown, createDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils'; + +import Collection from '@ckeditor/ckeditor5-utils/src/collection'; +import Model from '@ckeditor/ckeditor5-ui/src/model'; + +export default class PartDBLabelUI extends Plugin { + init() { + const editor = this.editor; + const t = editor.t; + + // The "placeholder" dropdown must be registered among the UI components of the editor + // to be displayed in the toolbar. + editor.ui.componentFactory.add( 'partdb_label', locale => { + const dropdownView = createDropdown( locale ); + + // Populate the list in the dropdown with items. + addListToDropdown( dropdownView, getDropdownItemsDefinitions(t) ); + + dropdownView.buttonView.set( { + // The t() function helps localize the editor. All strings enclosed in t() can be + // translated and change when the language of the editor changes. + label: t( 'Label Placeholder' ), + tooltip: true, + withText: true + } ); + + // Disable the placeholder button when the command is disabled. + const command = editor.commands.get( 'partdb_label' ); + dropdownView.bind( 'isEnabled' ).to( command ); + + // Execute the command when the dropdown item is clicked (executed). + this.listenTo( dropdownView, 'execute', evt => { + editor.execute( 'partdb_label', { value: evt.source.commandParam } ); + editor.editing.view.focus(); + } ); + + return dropdownView; + } ); + } +} + +const PLACEHOLDERS = [ + { + label: 'Part', + entries: [ + ['[[ID]]', 'Database ID'], + ['[[NAME]]', 'Part name'], + ['[[CATEGORY]]', 'Category'], + ['[[CATEGORY_FULL]]', 'Category (Full path)'], + ['[[MANUFACTURER]]', 'Manufacturer'], + ['[[MANUFACTURER_FULL]]', 'Manufacturer (Full path)'], + ['[[FOOTPRINT]]', 'Footprint'], + ['[[FOOTPRINT_FULL]]', 'Footprint (Full path)'], + ['[[MASS]]', 'Mass'], + ['[[MPN]]', 'Manufacturer Product Number (MPN)'], + ['[[TAGS]]', 'Tags'], + ['[[M_STATUS]]', 'Manufacturing status'], + ['[[DESCRIPTION]]', 'Description'], + ['[[DESCRIPTION_T]]', 'Description (plain text)'], + ['[[COMMENT]]', 'Comment'], + ['[[COMMENT_T]]', 'Comment (plain text)'], + ['[[LAST_MODIFIED]]', 'Last modified datetime'], + ['[[CREATION_DATE]]', 'Creation datetime'], + ] + }, + { + label: 'Part lot', + entries: [ + ['[[LOT_ID]]', 'Lot ID'], + ['[[LOT_NAME]]', 'Lot name'], + ['[[LOT_COMMENT]]', 'Lot comment'], + ['[[EXPIRATION_DATE]]', 'Lot expiration date'], + ['[[AMOUNT]]', 'Lot amount'], + ['[[LOCATION]]', 'Storage location'], + ['[[LOCATION_FULL]]', 'Storage location (Full path)'], + ] + }, + { + label: 'Storage location', + entries: [ + ['[[ID]]', 'Location ID'], + ['[[NAME]]', 'Name'], + ['[[FULL_PATH]]', 'Full path'], + ['[[PARENT]]', 'Parent name'], + ['[[PARENT_FULL_PATH]]', 'Parent full path'], + ['[[COMMENT]]', 'Comment'], + ['[[COMMENT_T]]', 'Comment (plain text)'], + ['[[LAST_MODIFIED]]', 'Last modified datetime'], + ['[[CREATION_DATE]]', 'Creation datetime'], + ] + }, + { + label: 'Globals', + entries: [ + ['[[USERNAME]]', 'Username'], + ['[[USERNAME_FULL]]', 'Username (including name)'], + ['[[DATETIME]]', 'Current datetime'], + ['[[DATE]]', 'Current date'], + ['[[TIME]]', 'Current time'], + ['[[INSTALL_NAME]]', 'Instance name'], + ['[[TYPE]]', 'Target type'] + ], + }, +]; + + +function getDropdownItemsDefinitions(t) { + const itemDefinitions = new Collection(); + + for ( const group of PLACEHOLDERS) { + //Add group header + itemDefinitions.add({ + 'type': 'separator', + model: new Model( { + withText: true, + }) + }); + + itemDefinitions.add({ + type: 'button', + model: new Model( { + label: t(group.label), + withText: true, + isEnabled: false, + } ) + }); + + //Add group entries + for ( const entry of group.entries) { + const definition = { + type: 'button', + model: new Model( { + commandParam: entry[0], + label: t(entry[1]), + withText: true + } ), + }; + + // Add the item definition to the collection. + itemDefinitions.add( definition ); + } + } + + return itemDefinitions; +} \ No newline at end of file diff --git a/assets/ckeditor/plugins/PartDBLabel/lang/de.js b/assets/ckeditor/plugins/PartDBLabel/lang/de.js new file mode 100644 index 00000000..e96df8c5 --- /dev/null +++ b/assets/ckeditor/plugins/PartDBLabel/lang/de.js @@ -0,0 +1,54 @@ +// Make sure that the global object is defined. If not, define it. +window.CKEDITOR_TRANSLATIONS = window.CKEDITOR_TRANSLATIONS || {}; + +// Make sure that the dictionary for Polish translations exist. +window.CKEDITOR_TRANSLATIONS[ 'de' ] = window.CKEDITOR_TRANSLATIONS[ 'de' ] || {}; +window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary = window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary || {}; + +// Extend the dictionary for Polish translations with your translations: +Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, { + 'Label Placeholder': 'Label Platzhalter', + 'Part': 'Bauteil', + + 'Database ID': 'Datenbank ID', + 'Part name': 'Bauteilname', + 'Category': 'Kategorie', + 'Category (Full path)': 'Kategorie (Vollständiger Pfad)', + 'Manufacturer': 'Hersteller', + 'Manufacturer (Full path)': 'Hersteller (Vollständiger Pfad)', + 'Footprint': 'Footprint', + 'Footprint (Full path)': 'Footprint (Vollständiger Pfad)', + 'Mass': 'Gewicht', + 'Manufacturer Product Number (MPN)': 'Hersteller Produktnummer (MPN)', + 'Tags': 'Tags', + 'Manufacturing status': 'Herstellungsstatus', + 'Description': 'Beschreibung', + 'Description (plain text)': 'Beschreibung (Nur-Text)', + 'Comment': 'Kommentar', + 'Comment (plain text)': 'Kommentar (Nur-Text)', + 'Last modified datetime': 'Zuletzt geändert', + 'Creation datetime': 'Erstellt', + + 'Lot ID': 'Lot ID', + 'Lot name': 'Lot Name', + 'Lot comment': 'Lot Kommentar', + 'Lot expiration date': 'Lot Ablaufdatum', + 'Lot amount': 'Lot Menge', + 'Storage location': 'Lagerort', + 'Storage location (Full path)': 'Lagerort (Vollständiger Pfad)', + + 'Location ID': 'Lagerort ID', + 'Name': 'Name', + 'Full path': 'Vollständiger Pfad', + 'Parent name': 'Name des Übergeordneten Elements', + 'Parent full path': 'Ganzer Pfad des Übergeordneten Elements', + + 'Username': 'Benutzername', + 'Username (including name)': 'Benutzername (inklusive Name)', + 'Current datetime': 'Aktuelle Datum/Zeit', + 'Current date': 'Aktuelles Datum', + 'Current time': 'Aktuelle Zeit', + 'Instance name': 'Instanzname', + 'Target type': 'Zieltyp', + +} ); \ No newline at end of file diff --git a/assets/ckeditor/plugins/extendedMarkdown.js b/assets/ckeditor/plugins/extendedMarkdown.js new file mode 100644 index 00000000..8ce1c9c0 --- /dev/null +++ b/assets/ckeditor/plugins/extendedMarkdown.js @@ -0,0 +1,53 @@ +import { Plugin } from 'ckeditor5/src/core'; +import GFMDataProcessor from '@ckeditor/ckeditor5-markdown-gfm/src/gfmdataprocessor'; + +const ALLOWED_TAGS = [ + //Common elements + 'sup', + 'sub', + 'u', + 'kbd', + 'mark', + 'ins', + 'small', + 'abbr', + 'br', + + //Block elements + 'span', + 'p', + 'img', + + + + //These commands are somehow ignored: TODO + 'left', + 'center', + 'right', +]; + +/** + * The GitHub Flavored Markdown (GFM) plugin with added HTML tags, which are kept in the output. (inline mode) + * + */ +export default class ExtendedMarkdown extends Plugin { + + /** + * @inheritDoc + */ + constructor( editor ) { + super( editor ); + + editor.data.processor = new GFMDataProcessor( editor.data.viewDocument ); + for (const tag of ALLOWED_TAGS) { + editor.data.processor.keepHtml(tag); + } + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'Markdown'; + } +} diff --git a/assets/ckeditor/plugins/extendedMarkdownInline.js b/assets/ckeditor/plugins/extendedMarkdownInline.js new file mode 100644 index 00000000..dc8eb5e0 --- /dev/null +++ b/assets/ckeditor/plugins/extendedMarkdownInline.js @@ -0,0 +1,42 @@ +import { Plugin } from 'ckeditor5/src/core'; +import GFMDataProcessor from '@ckeditor/ckeditor5-markdown-gfm/src/gfmdataprocessor'; + +const ALLOWED_TAGS = [ + //Common elements + 'sup', + 'sub', + 'u', + 'kbd', + 'mark', + 'ins', + 'small', + 'abbr', + 'br', + 'span', +]; + + +/** + * The GitHub Flavored Markdown (GFM) plugin with added HTML tags, which are kept in the output. (inline mode) + * + */ +export default class ExtendedMarkdownInline extends Plugin { + /** + * @inheritDoc + */ + constructor( editor ) { + super( editor ); + + editor.data.processor = new GFMDataProcessor( editor.data.viewDocument ); + for (const tag of ALLOWED_TAGS) { + editor.data.processor.keepHtml(tag); + } + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'Markdown'; + } +} diff --git a/assets/ckeditor/plugins/singleLine.js b/assets/ckeditor/plugins/singleLine.js new file mode 100644 index 00000000..deb75ad4 --- /dev/null +++ b/assets/ckeditor/plugins/singleLine.js @@ -0,0 +1,29 @@ +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; + +export default class SingleLinePlugin extends Plugin { + init() { + const editor = this.editor; + const view = editor.editing.view; + const viewDocument = view.document; + + //Listen to enter presses + this.listenTo( viewDocument, 'enter', ( evt, data ) => { + //If user presses enter, prevent the enter action + evt.stop(); + }, { priority: 'high' } ); + + //And clipboard pastes + this.listenTo( viewDocument, 'clipboardInput', ( evt, data ) => { + let dataTransfer = data.dataTransfer; + + //Clean text input (replace newlines with spaces) + let input = dataTransfer.getData("text"); + let cleaned = input.replace(/\r?\n/g, ' '); + + //We can not use the dataTransfer.setData method because the old object is somehow protected + data.dataTransfer = new DataTransfer(); + data.dataTransfer.setData("text", cleaned); + + }, { priority: 'high' } ); + } +} \ No newline at end of file diff --git a/assets/ckeditor/plugins/special_characters_emoji.js b/assets/ckeditor/plugins/special_characters_emoji.js new file mode 100644 index 00000000..408b00be --- /dev/null +++ b/assets/ckeditor/plugins/special_characters_emoji.js @@ -0,0 +1,26 @@ +import SpecialCharacters from '@ckeditor/ckeditor5-special-characters/src/specialcharacters'; +import SpecialCharactersEssentials from '@ckeditor/ckeditor5-special-characters/src/specialcharactersessentials'; + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; + +const emoji = require('emoji.json'); + +export default class SpecialCharactersEmoji extends Plugin { + + init() { + const editor = this.editor; + const specialCharsPlugin = editor.plugins.get('SpecialCharacters'); + + specialCharsPlugin.addItems('Emoji', this.getEmojis()); + } + + getEmojis() { + //Map our emoji data to the format the plugin expects + return emoji.map(emoji => { + return { + title: emoji.name, + character: emoji.char + }; + }); + } +} \ No newline at end of file diff --git a/assets/controllers.json b/assets/controllers.json index a1c6e90c..7ba2551d 100644 --- a/assets/controllers.json +++ b/assets/controllers.json @@ -1,4 +1,11 @@ { - "controllers": [], + "controllers": { + "@symfony/ux-turbo": { + "turbo-core": { + "enabled": true, + "fetch": "eager" + } + } + }, "entrypoints": [] } diff --git a/assets/controllers/common/back_to_top_controller.js b/assets/controllers/common/back_to_top_controller.js new file mode 100644 index 00000000..334b9126 --- /dev/null +++ b/assets/controllers/common/back_to_top_controller.js @@ -0,0 +1,27 @@ +import {Controller} from "@hotwired/stimulus"; +import {Tooltip} from "bootstrap"; + +export default class extends Controller { + + connect() { + window.addEventListener('scroll', this._onscroll.bind(this)); + } + + _onscroll() { + const button = this.element; + + if (document.body.scrollTop > 20 || document.documentElement.scrollTop > 20) { + button.style.display = "block"; + } else { + button.style.display = "none"; + } + } + + backToTop() { + //Hide button tooltip to prevent ugly tooltip on scroll + Tooltip.getInstance(this.element)?.hide(); + + document.body.scrollTop = 0; // For Safari + document.documentElement.scrollTop = 0; // For Chrome, Firefox, IE and Opera + } +} \ No newline at end of file diff --git a/assets/controllers/common/darkmode_controller.js b/assets/controllers/common/darkmode_controller.js new file mode 100644 index 00000000..6f2afb69 --- /dev/null +++ b/assets/controllers/common/darkmode_controller.js @@ -0,0 +1,41 @@ +import {Controller} from "@hotwired/stimulus"; +import Darkmode from "darkmode-js/src"; +import "darkmode-js" + +export default class extends Controller { + + _darkmode; + + connect() { + if (typeof window.getComputedStyle(document.body).mixBlendMode == 'undefined') { + console.warn("The browser does not support mix blend mode. Darkmode will not work."); + return; + } + + try { + const darkmode = new Darkmode(); + this._darkmode = darkmode; + + //Unhide darkmode button + this._showWidget(); + + //Set the switch according to our current darkmode state + const toggler = document.getElementById("toggleDarkmode"); + toggler.checked = darkmode.isActivated(); + } + catch (e) + { + console.error(e); + } + + + } + + _showWidget() { + this.element.classList.remove('hidden'); + } + + toggleDarkmode() { + this._darkmode.toggle(); + } +} \ No newline at end of file diff --git a/assets/controllers/common/hide_sidebar_controller.js b/assets/controllers/common/hide_sidebar_controller.js new file mode 100644 index 00000000..dbeb8073 --- /dev/null +++ b/assets/controllers/common/hide_sidebar_controller.js @@ -0,0 +1,73 @@ +import {Controller} from "@hotwired/stimulus"; + +const STORAGE_KEY = 'hide_sidebar'; + +export default class extends Controller { + /** + * The element representing the sidebar which can be hidden. + * @type {HTMLElement} + * @private + */ + _sidebar; + + /** + * The element of the container which is expanded to the full width. + * @type {HTMLElement} + * @private + */ + _container; + + /** + * The button which toggles the sidebar. + * @private + */ + _toggle_button; + + _hidden = false; + + connect() { + this._sidebar = document.getElementById('fixed-sidebar'); + this._container = document.getElementById('main'); + this._toggle_button = this.element; + + //Make the state persistent over reloads + if(localStorage.getItem(STORAGE_KEY) === 'true') { + sidebarHide(); + } + } + + hideSidebar() { + this._sidebar.classList.add('d-none'); + + this._container.classList.remove(...['col-md-9', 'col-lg-10', 'offset-md-3', 'offset-lg-2']); + this._container.classList.add('col-12'); + + //Change button icon + this._toggle_button.innerHTML = ''; + + localStorage.setItem(STORAGE_KEY, 'true'); + this._hidden = true; + } + + showSidebar() { + this._sidebar.classList.remove('d-none'); + + this._container.classList.remove('col-12'); + this._container.classList.add(...['col-md-9', 'col-lg-10', 'offset-md-3', 'offset-lg-2']); + + + //Change button icon + this._toggle_button.innerHTML = ''; + + localStorage.setItem(STORAGE_KEY, 'false'); + this._hidden = false; + } + + toggleSidebar() { + if(this._hidden) { + this.showSidebar(); + } else { + this.hideSidebar(); + } + } +} \ No newline at end of file diff --git a/assets/controllers/common/latex_controller.js b/assets/controllers/common/latex_controller.js new file mode 100644 index 00000000..4d24daa7 --- /dev/null +++ b/assets/controllers/common/latex_controller.js @@ -0,0 +1,36 @@ +import {Controller} from "@hotwired/stimulus"; + +//import "katex"; +import 'katex/dist/katex.css'; +import {auto} from "@popperjs/core"; +//import renderMathInElement from "katex/dist/contrib/auto-render"; + +export default class extends Controller { + connect() { + this.applyLatex(); + this.element.addEventListener('markdown:finished', () => this.applyLatex()); + } + + applyLatex() { + //Only import the katex library, if we have an delimiter string in our element text + let str = this.element.textContent; + if(str.match(/(\$|\\\(|\\\[).+(\$|\\\)|\\\])/)) { + import('katex/dist/contrib/auto-render').then((autorender) => { + //This calls renderMathInElement() + autorender.default(this.element, { + delimiters: [ + {left: "$$", right: "$$", display: true}, + {left: "$", right: "$", display: false}, + {left: "\\(", right: "\\)", display: false}, + {left: "\\[", right: "\\]", display: true} + ] + }); + }) + } + + } + + mutate() { + this.applyLatex(); + } +} \ No newline at end of file diff --git a/assets/controllers/common/markdown_controller.js b/assets/controllers/common/markdown_controller.js new file mode 100644 index 00000000..70fd4d90 --- /dev/null +++ b/assets/controllers/common/markdown_controller.js @@ -0,0 +1,69 @@ +'use strict'; + +import { Controller } from '@hotwired/stimulus'; +import { marked } from "marked"; +import DOMPurify from 'dompurify'; + +import "../../css/markdown.css"; + +export default class extends Controller { + + connect() + { + this.configureMarked(); + this.render(); + + //Dispatch an event that we are now finished + const event = new CustomEvent('markdown:finished', { + bubbles: true + }); + this.element.dispatchEvent(event); + } + + render() { + let raw = this.element.dataset['markdown']; + + //Apply purified parsed markdown + this.element.innerHTML = DOMPurify.sanitize(marked(this.unescapeHTML(raw))); + + for(let a of this.element.querySelectorAll('a')) { + //Mark all links as external + a.classList.add('link-external'); + //Open links in new tag + a.setAttribute('target', '_blank'); + //Dont track + a.setAttribute('rel', 'noopener'); + } + + //Apply bootstrap styles to tables + for(let table of this.element.querySelectorAll('table')) { + table.classList.add('table', 'table-hover', 'table-striped', 'table-bordered', 'table-sm'); + } + + //Make header line dark + for(let head of this.element.querySelectorAll('thead')) { + head.classList.add('table-dark'); + } + } + + /** + * Unescape the given HTML + * @param {string} html + * @returns {string} + */ + unescapeHTML(html) { + var txt = document.createElement('textarea'); + txt.innerHTML = html; + return txt.value; + } + + /** + * Configure the marked parser + */ + configureMarked() + { + marked.setOptions({ + gfm: true, + }); + } +} \ No newline at end of file diff --git a/assets/controllers/common/toast_controller.js b/assets/controllers/common/toast_controller.js new file mode 100644 index 00000000..8dc7914c --- /dev/null +++ b/assets/controllers/common/toast_controller.js @@ -0,0 +1,17 @@ +import { Controller } from '@hotwired/stimulus'; +import { Toast } from 'bootstrap'; + +export default class extends Controller { + connect() { + //Move all toasts from the page into our toast container and show them + + const toastContainer = document.getElementById('toast-container'); + if (this.element.parentNode !== toastContainer) { + toastContainer.appendChild(this.element); + return; + } + + const toast = new Toast(this.element); + toast.show(); + } +} \ No newline at end of file diff --git a/assets/controllers/elements/attachment_autocomplete_controller.js b/assets/controllers/elements/attachment_autocomplete_controller.js new file mode 100644 index 00000000..df6f3bf0 --- /dev/null +++ b/assets/controllers/elements/attachment_autocomplete_controller.js @@ -0,0 +1,60 @@ +import {Controller} from "@hotwired/stimulus"; + +import "tom-select/dist/css/tom-select.bootstrap5.css"; +import '../../css/tom-select_extensions.css'; +import TomSelect from "tom-select"; + +export default class extends Controller { + _tomSelect; + + connect() { + + let settings = { + persistent: false, + create: true, + maxItems: 1, + createOnBlur: true, + render: { + item: (data, escape) => { + return '' + escape(data.label) + ''; + }, + option: (data, escape) => { + if (data.image) { + return "
" + data.label + "
" + } + return '
' + escape(data.label) + '
'; + } + } + }; + + if(this.element.dataset.autocomplete) { + const base_url = this.element.dataset.autocomplete; + settings.searchField = "label"; + settings.sortField = "label"; + settings.valueField = "label"; + settings.load = (query, callback) => { + if(query.length < 2){ + callback(); + return; + } + const url = base_url.replace('__QUERY__', encodeURIComponent(query)); + + fetch(url) + .then(response => response.json()) + .then(json => { + const data = json.map(x => { + return { + "label": x.name, + "image": x.image, + } + }); + callback(data); + }).catch(()=>{ + callback(); + }); + }; + } + this._tomSelect = new TomSelect(this.element, settings); + } + +} diff --git a/assets/controllers/elements/attachmenttype_change_controller.js b/assets/controllers/elements/attachmenttype_change_controller.js new file mode 100644 index 00000000..d96e70f3 --- /dev/null +++ b/assets/controllers/elements/attachmenttype_change_controller.js @@ -0,0 +1,29 @@ +import {Controller} from "@hotwired/stimulus"; + +/** + * This controller synchronizes the filetype filters of the file input type with our selected attachment type + */ +export default class extends Controller +{ + _selectInput; + _fileInput; + + connect() { + //Find the select input for our attachment form + this._selectInput = this.element.querySelector('select'); + //Find the file input for our attachment form + this._fileInput = this.element.querySelector('input[type="file"]'); + + this._selectInput.addEventListener('change', this.updateAllowedFiletypes.bind(this)); + + //Update file file on load + this.updateAllowedFiletypes(); + } + + updateAllowedFiletypes() { + let selected_option = this._selectInput.options[this._selectInput.selectedIndex]; + let filetype_filter = selected_option.dataset.filetype_filter; + //Apply filetype filter to file input + this._fileInput.setAttribute('accept', filetype_filter); + } +} \ No newline at end of file diff --git a/assets/controllers/elements/ckeditor_controller.js b/assets/controllers/elements/ckeditor_controller.js new file mode 100644 index 00000000..87607709 --- /dev/null +++ b/assets/controllers/elements/ckeditor_controller.js @@ -0,0 +1,57 @@ +import {Controller} from "@hotwired/stimulus"; + +import { default as FullEditor } from "../../ckeditor/markdown_full"; +import { default as SingleLineEditor} from "../../ckeditor/markdown_single_line"; +import { default as HTMLLabelEditor } from "../../ckeditor/html_label"; + +import EditorWatchdog from '@ckeditor/ckeditor5-watchdog/src/editorwatchdog'; + + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + connect() { + const mode = this.element.dataset.mode; + + let EDITOR_TYPE = "Invalid"; + + switch (mode) { + case "markdown-full": + EDITOR_TYPE = FullEditor['Editor']; + break; + case "markdown-single_line": + EDITOR_TYPE = SingleLineEditor['Editor']; + break; + case "html-label": + EDITOR_TYPE = HTMLLabelEditor['Editor']; + break; + default: + console.error("Unknown mode: " + mode); + return; + } + + const language = document.body.dataset.locale ?? "en"; + + const config = { + language: language, + } + + const watchdog = new EditorWatchdog(); + watchdog.setCreator((elementOrData, editorConfig) => { + return EDITOR_TYPE.create(elementOrData, editorConfig) + .then(editor => { + if(this.element.disabled) { + editor.enableReadOnlyMode("readonly"); + } + + console.log(editor); + }) + .catch(error => { + console.error(error); + }); + }); + + watchdog.create(this.element, config).catch(error => { + console.error(error); + }); + } +} \ No newline at end of file diff --git a/assets/controllers/elements/collection_type_controller.js b/assets/controllers/elements/collection_type_controller.js new file mode 100644 index 00000000..4c804534 --- /dev/null +++ b/assets/controllers/elements/collection_type_controller.js @@ -0,0 +1,114 @@ +import {Controller} from "@hotwired/stimulus"; + +import * as bootbox from "bootbox"; +import "../../css/bootbox_extensions.css"; + +export default class extends Controller { + static values = { + deleteMessage: String, + prototype: String, + } + + static targets = ["target"]; + + /** + * Decodes escaped HTML entities + * @param {string} input + * @returns {string} + */ + htmlDecode(input) { + const doc = new DOMParser().parseFromString(input, "text/html"); + return doc.documentElement.textContent; + } + + /** + * Generates a unique ID to be used for the new element + * @returns {string} + */ + generateUID() { + const long = (performance.now().toString(36)+Math.random().toString(36)).replace(/\./g,""); + return long.slice(0, 6); // 6 characters is enough for our unique IDs here + } + + + /** + * Create a new entry in the target using the given prototype value + */ + createElement(event) { + const targetTable = this.targetTarget; + const prototype = this.prototypeValue + + if(!prototype) { + console.warn("Prototype is not set, we cannot create a new element. This is most likely due to missing permissions."); + bootbox.alert("You do not have the permsissions to create a new element. (No protoype element is set)"); + return; + } + + + //Apply the index to prototype to create our element to insert + const newElementStr = this.htmlDecode(prototype.replace(/__name__/g, this.generateUID())); + + + //Insert new html after the last child element + //If the table has a tbody, insert it there + if(targetTable.tBodies[0]) { + targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr); + } else { //Otherwise just insert it + targetTable.insertAdjacentHTML('beforeend', newElementStr); + } + } + + /** + * Similar to createEvent Pricedetails need some special handling to fill min amount + * @param event + */ + createPricedetail(event) { + //First insert our new element + this.createElement(event); + + const extractElementsFromRow = (row) => { + const priceRelated = row.querySelector("input[id$='price_related_quantity_value']"); + const minDiscount = row.querySelector("input[id$='min_discount_quantity_value']"); + + return [priceRelated, minDiscount]; + } + + const targetTable = this.targetTarget; + const targetRows = targetTable.tBodies[0].rows; + const targetRowsCount = targetRows.length; + + //If we just have one element we dont have to do anything as 1 is already the default + if(targetRowsCount <= 1) { + return; + } + + //Our new element is the last child of the table + const newlyCreatedRow = targetRows[targetRowsCount - 1]; + const [newPriceRelated, newMinDiscount] = extractElementsFromRow(newlyCreatedRow); + + const oldRow = targetRows[targetRowsCount - 2]; + const [oldPriceRelated, oldMinDiscount] = extractElementsFromRow(oldRow); + + //Use the old PriceRelated value to determine the next 10 decade value for the new row + const oldMinAmount = parseInt(oldMinDiscount.value) + //The next 10 power can be achieved by creating a string beginning with "1" and adding 0 times the length of the old string + const oldMinAmountLength = oldMinAmount.toString().length; + const newMinAmountStr = '1' + '0'.repeat(oldMinAmountLength); + //Parse the sting back to an integer and we have our new min amount + const newMinAmount = parseInt(newMinAmountStr); + + + //Assign it to our new element + newMinDiscount.value = newMinAmount; + } + + deleteElement(event) { + bootbox.confirm(this.deleteMessageValue, (result) => { + if(result) { + const target = event.target; + //Remove the row element from the table + target.closest("tr").remove(); + } + }); + } +} \ No newline at end of file diff --git a/assets/controllers/elements/datatables/datatables_controller.js b/assets/controllers/elements/datatables/datatables_controller.js new file mode 100644 index 00000000..25763570 --- /dev/null +++ b/assets/controllers/elements/datatables/datatables_controller.js @@ -0,0 +1,145 @@ +import {Controller} from "@hotwired/stimulus"; + +//Styles +import 'datatables.net-bs5/css/dataTables.bootstrap5.css' +import 'datatables.net-buttons-bs5/css/buttons.bootstrap5.css' +import 'datatables.net-fixedheader-bs5/css/fixedHeader.bootstrap5.css' +import 'datatables.net-select-bs5/css/select.bootstrap5.css' +import 'datatables.net-responsive-bs5/css/responsive.bootstrap5.css'; + +//JS +import 'datatables.net-bs5'; +import 'datatables.net-buttons-bs5'; +import 'datatables.net-buttons/js/buttons.colVis.js'; +import 'datatables.net-fixedheader-bs5'; +import 'datatables.net-select-bs5'; +import 'datatables.net-colreorder-bs5'; +import 'datatables.net-responsive-bs5'; +import '../../../js/lib/datatables'; + +const EVENT_DT_LOADED = 'dt:loaded'; + +export default class extends Controller { + + static targets = ['dt']; + + /** The datatable instance associated with this controller instance */ + _dt; + + connect() { + //$($.fn.DataTable.tables()).DataTable().fixedHeader.disable(); + //$($.fn.DataTable.tables()).DataTable().destroy(); + + const settings = JSON.parse(this.element.dataset.dtSettings); + if(!settings) { + throw new Error("No settings provided for datatable!"); + } + + //Add url info, as the one available in the history is not enough, as Turbo may have not changed it yet + settings.url = this.element.dataset.dtUrl; + + + //@ts-ignore + const promise = $(this.dtTarget).initDataTables(settings, + { + colReorder: true, + responsive: true, + fixedHeader: { + header: $(window).width() >= 768, //Only enable fixedHeaders on devices with big screen. Fixes scrolling issues on smartphones. + headerOffset: $("#navbar").height() + }, + buttons: [{ + "extend": 'colvis', + 'className': 'mr-2 btn-light', + "text": "" + }], + select: this.isSelectable(), + rowCallback: this._rowCallback.bind(this), + }) + //Register error handler + .catch(err => { + console.error("Error initializing datatables: " + err); + }); + + //Dispatch an event to let others know that the datatables has been loaded + promise.then((dt) => { + const event = new CustomEvent(EVENT_DT_LOADED, {bubbles: true}); + this.element.dispatchEvent(event); + + this._dt = dt; + }); + + //Register event handlers + promise.then((dt) => { + dt.on('select.dt deselect.dt', this._onSelectionChange.bind(this)); + }); + + //Allow to further configure the datatable + promise.then(this._afterLoaded.bind(this)); + + + + //Register links. + /*promise.then(function() { + + //Set the correct title in the table. + let title = $('#part-card-header-src'); + $('#part-card-header').html(title.html()); + $(document).trigger('ajaxUI:dt_loaded'); + + + if($table.data('part_table')) { + //@ts-ignore + $('#dt').on( 'select.dt deselect.dt', function ( e, dt, items ) { + let selected_elements = dt.rows({selected: true}); + let count = selected_elements.count(); + + if(count > 0) { + $('#select_panel').removeClass('d-none'); + } else { + $('#select_panel').addClass('d-none'); + } + + $('#select_count').text(count); + + let selected_ids_string = selected_elements.data().map(function(value, index) { + return value['id']; } + ).join(","); + + $('#select_ids').val(selected_ids_string); + + } ); + } + + //Attach event listener to update links after new page selection: + $('#dt').on('draw.dt column-visibility.dt', function() { + //ajaxUI.registerLinks(); + $(document).trigger('ajaxUI:dt_loaded'); + }); + });*/ + + console.debug('Datatables inited.'); + } + + _rowCallback(row, data, index) { + //Empty by default but can be overridden by child classes + } + + _onSelectionChange(e, dt, items ) { + //Empty by default but can be overridden by child classes + alert("Test"); + } + + _afterLoaded(dt) { + //Empty by default but can be overridden by child classes + } + + /** + * Check if this datatable has selection feature enabled + */ + isSelectable() + { + return this.element.dataset.select ?? false; + } + +} \ No newline at end of file diff --git a/assets/controllers/elements/datatables/log_controller.js b/assets/controllers/elements/datatables/log_controller.js new file mode 100644 index 00000000..8362477b --- /dev/null +++ b/assets/controllers/elements/datatables/log_controller.js @@ -0,0 +1,32 @@ +import DatatablesController from "./datatables_controller.js"; + +/** + * This is the datatables controller for log pages, it includes an mechanism to color lines based on their level. + */ +export default class extends DatatablesController { + _rowCallback(row, data, index) { + //Check if we have a level, then change color of this row + if (data.level) { + let style = ""; + switch (data.level) { + case "emergency": + case "alert": + case "critical": + case "error": + style = "table-danger"; + break; + case "warning": + style = "table-warning"; + break; + case "notice": + style = "table-info"; + break; + } + + if (style) { + $(row).addClass(style); + } + } + } +} + diff --git a/assets/controllers/elements/datatables/parts_controller.js b/assets/controllers/elements/datatables/parts_controller.js new file mode 100644 index 00000000..aff205b2 --- /dev/null +++ b/assets/controllers/elements/datatables/parts_controller.js @@ -0,0 +1,128 @@ +import DatatablesController from "./datatables_controller.js"; + +import * as bootbox from "bootbox"; + +/** + * This is the datatables controller for parts lists + */ +export default class extends DatatablesController { + + static targets = ['dt', 'selectPanel', 'selectIDs', 'selectCount', 'selectTargetPicker']; + + _confirmed = false; + + isSelectable() { + //Parts controller is always selectable + return true; + } + + _onSelectionChange(e, dt, items) { + const selected_elements = dt.rows({selected: true}); + const count = selected_elements.count(); + + const selectPanel = this.selectPanelTarget; + + //Hide/Unhide panel with the selection tools + if (count > 0) { + selectPanel.classList.remove('d-none'); + } else { + selectPanel.classList.add('d-none'); + } + + //Update selection count text + this.selectCountTarget.innerText = count; + + //Fill selection ID input + let selected_ids_string = selected_elements.data().map(function(value, index) { + return value['id']; } + ).join(","); + + this.selectIDsTarget.value = selected_ids_string; + } + + updateOptions(select_element, json) + { + //Clear options + select_element.innerHTML = null; + $(select_element).selectpicker('destroy'); + + for(let i=0; i { + response.json().then(json => { + this.updateOptions(select_target, json); + }); + }); + } else { + $(select_target).selectpicker('hide'); + } + } + + confirmDeletionAtSubmit(event) { + //Only show the dialog when selected action is delete + if (event.target.elements["action"].value !== "delete") { + return; + } + + //If a user has not already confirmed the deletion, just let turbo do its work + if(this._confirmed) { + this._confirmed = false; + return; + } + + //Prevent turbo from doing its work + event.preventDefault(); + + const message = this.element.dataset.deleteMessage; + const title = this.element.dataset.deleteTitle; + + const form = this.element; + const that = this; + + //Create a clone of the event with the same submitter, so we can redispatch it if needed + //We need to do this that way, as we need the submitter info, just calling form.submit() would not work + this._our_event = new SubmitEvent('submit', { + submitter: event.submitter, + bubbles: true, //This line is important, otherwise Turbo will not receive the event + }); + + const confirm = bootbox.confirm({ + message: message, title: title, callback: function (result) { + //If the dialog was confirmed, then submit the form. + if (result) { + that._confirmed = true; + form.dispatchEvent(that._our_event); + } else { + that._confirmed = false; + } + } + }); + } +} + diff --git a/assets/controllers/elements/delete_btn_controller.js b/assets/controllers/elements/delete_btn_controller.js new file mode 100644 index 00000000..0a8c90c2 --- /dev/null +++ b/assets/controllers/elements/delete_btn_controller.js @@ -0,0 +1,48 @@ +import {Controller} from "@hotwired/stimulus"; + +import * as bootbox from "bootbox"; +import "../../css/bootbox_extensions.css"; + +export default class extends Controller +{ + connect() + { + this._confirmed = false; + } + + submit(event) { + //If a user has not already confirmed the deletion, just let turbo do its work + if(this._confirmed) { + this._confirmed = false; + return; + } + + //Prevent turbo from doing its work + event.preventDefault(); + + const message = this.element.dataset.deleteMessage; + const title = this.element.dataset.deleteTitle; + + const form = this.element; + const that = this; + + //Create a clone of the event with the same submitter, so we can redispatch it if needed + //We need to do this that way, as we need the submitter info, just calling form.submit() would not work + this._our_event = new SubmitEvent('submit', { + submitter: event.submitter, + bubbles: true, //This line is important, otherwise Turbo will not receive the event + }); + + const confirm = bootbox.confirm({ + message: message, title: title, callback: function (result) { + //If the dialog was confirmed, then submit the form. + if (result) { + that._confirmed = true; + form.dispatchEvent(that._our_event); + } else { + that._confirmed = false; + } + } + }); + } +} \ No newline at end of file diff --git a/assets/controllers/elements/hoverpic_controller.js b/assets/controllers/elements/hoverpic_controller.js new file mode 100644 index 00000000..5a5a9580 --- /dev/null +++ b/assets/controllers/elements/hoverpic_controller.js @@ -0,0 +1,21 @@ +import {Controller} from "@hotwired/stimulus"; +import {Popover} from "bootstrap"; + +export default class extends Controller { + connect() { + const thumbnail_url = this.element.dataset.thumbnail; + + this._popover = Popover.getOrCreateInstance(this.element, { + html: true, + trigger: 'hover', + placement: 'right', + container: 'body', + 'title': this.element.dataset.title ?? '', + content: function () { + return ''; + } + }); + + this._popover.hide(); + } +} \ No newline at end of file diff --git a/assets/controllers/elements/preset_input_controller.js b/assets/controllers/elements/preset_input_controller.js new file mode 100644 index 00000000..08f795cc --- /dev/null +++ b/assets/controllers/elements/preset_input_controller.js @@ -0,0 +1,16 @@ +import {Controller} from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["input"]; + + connect() { + if(!this.inputTarget) { + throw new Error("Target input not found"); + } + } + + load(event) { + //Use the data-value attribute to load the value of our target input + this.inputTarget.value = event.target.dataset.value; + } +} \ No newline at end of file diff --git a/assets/controllers/elements/selectpicker_controller.js b/assets/controllers/elements/selectpicker_controller.js new file mode 100644 index 00000000..182a2e35 --- /dev/null +++ b/assets/controllers/elements/selectpicker_controller.js @@ -0,0 +1,15 @@ +const bootstrap = window.bootstrap = require('bootstrap'); // without this bootstrap-select crashes with `undefined bootstrap` +require('bootstrap-select/js/bootstrap-select'); // we have to manually require the working js file + +import {Controller} from "@hotwired/stimulus"; +import "bootstrap-select/dist/css/bootstrap-select.css"; +import "../../css/selectpicker_extensions.css"; + +export default class extends Controller { + connect() { + $(this.element).selectpicker({ + dropdownAlignRight: 'auto', + container: '#content', + }); + } +} \ No newline at end of file diff --git a/assets/controllers/elements/sidebar_tree_controller.js b/assets/controllers/elements/sidebar_tree_controller.js new file mode 100644 index 00000000..ca9ac515 --- /dev/null +++ b/assets/controllers/elements/sidebar_tree_controller.js @@ -0,0 +1,68 @@ +import {Controller} from "@hotwired/stimulus"; +import {default as TreeController} from "./tree_controller"; + +import "patternfly-bootstrap-treeview/src/css/bootstrap-treeview.css" +import "patternfly-bootstrap-treeview"; + +export default class extends TreeController { + static targets = [ "tree", 'sourceText' ]; + + _storage_key; + + connect() { + //Check if the tree is already initialized, if so then skip initialization (useful when going back) to in history using Turbo + if(this._isInitialized()) { + return; + } + + const default_mode = this.element.dataset.defaultMode; + + this._storage_key = 'tree_' + this.element.id; + + //Check if we have a saved mode + const stored_mode = localStorage.getItem(this._storage_key); + + //Use stored mode if possible, otherwise use default + if(stored_mode) { + try { + this.setMode(stored_mode); + } catch (e) { + console.error(e); + //If an error happenes, use the default mode + this.setMode(default_mode); + } + } else { + this.setMode(default_mode); + } + } + + setMode(mode) { + //Find the button for this mode + const modeButton = this.element.querySelector(`[data-mode="${mode}"]`); + if(!modeButton) { + throw new Error(`Could not find button for mode ${mode}`); + } + + //Get the url and text from the button + const url = modeButton.dataset.url; + const text = modeButton.dataset.text; + + this.sourceTextTarget.innerText = text; + + this.setURL(url); + } + + changeDataSource(event) + { + const mode = event.params.mode ?? event.target.dataset.mode; + const url = event.params.url ?? event.target.dataset.url; + const text = event.params.text ?? event.target.dataset.text; + + this.sourceTextTarget.innerText = text; + + this.setURL(url); + + //Save the mode in local storage + localStorage.setItem(this._storage_key, mode); + } +} diff --git a/assets/controllers/elements/tagsinput_controller.js b/assets/controllers/elements/tagsinput_controller.js new file mode 100644 index 00000000..4454089f --- /dev/null +++ b/assets/controllers/elements/tagsinput_controller.js @@ -0,0 +1,68 @@ +import {Controller} from "@hotwired/stimulus"; + +import "tom-select/dist/css/tom-select.bootstrap5.css"; +import '../../css/tom-select_extensions.css'; +import TomSelect from "tom-select"; + +export default class extends Controller { + _tomSelect; + + connect() { + + let settings = { + plugins: { + remove_button:{ + } + }, + persistent: false, + createOnBlur: true, + create: true, + }; + + if(this.element.dataset.autocomplete) { + const base_url = this.element.dataset.autocomplete; + settings.load = (query, callback) => { + if(query.length < 2){ + callback(); + return; + } + const url = base_url.replace('__QUERY__', encodeURIComponent(query)); + + fetch(url) + .then(response => response.json()) + .then(json => { + const data = json.map(x => {return {"value": x, "text": x}}); + callback(data); + }).catch(()=>{ + callback(); + }); + } + } + + this._tomSelect = new TomSelect(this.element, settings); + + /*if(this.element.dataset.autocomplete) { + const engine = new Bloodhound({ + //@ts-ignore + datumTokenizer: Bloodhound.tokenizers.obj.whitespace(''), + //@ts-ignore + queryTokenizer: Bloodhound.tokenizers.obj.whitespace(''), + remote: { + url: this.element.dataset.autocomplete, + wildcard: 'QUERY' + } + }); + + $(this.element).tagsinput({ + typeaheadjs: { + name: 'tags', + source: engine.ttAdapter() + } + }); + } else { // Init tagsinput without typeahead + $(this.element).tagsinput(); + }*/ + + + } +} \ No newline at end of file diff --git a/assets/controllers/elements/tree_controller.js b/assets/controllers/elements/tree_controller.js new file mode 100644 index 00000000..44a4ee34 --- /dev/null +++ b/assets/controllers/elements/tree_controller.js @@ -0,0 +1,130 @@ +import {Controller} from "@hotwired/stimulus"; + +import "patternfly-bootstrap-treeview/src/css/bootstrap-treeview.css" +import "patternfly-bootstrap-treeview"; + +export default class extends Controller { + static targets = [ "tree" ]; + + _url = null; + _data = null; + + connect() { + const treeElement = this.treeTarget; + if (!treeElement) { + console.error("You need to define a tree target for the controller!"); + return; + } + + this._url = this.element.dataset.treeUrl; + this._data = this.element.dataset.treeData; + + this.reinitTree(); + } + + reinitTree() + { + //Fetch data and initialize tree + this._getData() + .then(this._fillTree.bind(this)) + .catch((err) => { + console.error("Could not load the tree data: " + err); + }); + } + + setData(data) { + this._data = data; + this.reinitTree(); + } + + setURL(url) { + this._url = url; + this.reinitTree(); + } + + _fillTree(data) { + //Get primary color from css variable + const primary_color = getComputedStyle(document.documentElement).getPropertyValue('--bs-warning'); + + const tree = this.treeTarget; + + $(tree).treeview({ + data: data, + enableLinks: true, + showIcon: false, + showBorder: true, + searchResultBackColor: primary_color, + searchResultColor: '#000', + onNodeSelected: function (event, data) { + if (data.href) { + + //Simulate a click so we just change the inner frame + let a = document.createElement('a'); + a.setAttribute('href', data.href); + a.innerHTML = ""; + $(tree).append(a); + a.click(); + a.remove(); + } + }, + //onNodeContextmenu: contextmenu_handler, + expandIcon: "fas fa-plus fa-fw fa-treeview", + collapseIcon: "fas fa-minus fa-fw fa-treeview" + }) + .on('initialized', function () { + //Collapse all nodes after init + $(this).treeview('collapseAll', {silent: true}); + + //Reveal the selected ones + $(this).treeview('revealNode', [$(this).treeview('getSelected')]); + }); + } + + collapseAll() { + $(this.treeTarget).treeview('collapseAll', {silent: true}); + } + + expandAll() { + $(this.treeTarget).treeview('expandAll', {silent: true}); + } + + searchInput(event) { + const data = event.target.value; + //Do nothing if no data was passed + + const tree = this.treeTarget; + $(tree).treeview('collapseAll', {silent: true}); + $(tree).treeview('search', [data]); + } + + /** + * Check if the tree is already initialized (meaning bootstrap treeview was called on the object) + * @private + */ + _isInitialized() { + const $tree = $(this.treeTarget).treeview(true); + + //If the tree is not initialized yet, we just get an empty jquery object with the treeview functions missing + if(typeof $tree.findNodes === 'undefined' ) { + return false; + } + + return true; + + } + + _getData() { + //Use lambda function to preserve this context + return new Promise((myResolve, myReject) => { + //If a url is defined, fetch the data from the url + if (this._url) { + return fetch(this._url) + .then((response) => myResolve(response.json())) + .catch((err) => myReject(err)); + } + + //Otherwise load the data provided via the data attribute + return myResolve(this._data); + }); + } +} \ No newline at end of file diff --git a/assets/controllers/hello_controller.js b/assets/controllers/hello_controller.js deleted file mode 100644 index 8c79f65a..00000000 --- a/assets/controllers/hello_controller.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Controller } from 'stimulus'; - -/* - * This is an example Stimulus controller! - * - * Any element with a data-controller="hello" attribute will cause - * this controller to be executed. The name "hello" comes from the filename: - * hello_controller.js -> "hello" - * - * Delete this file or adapt it for your use! - */ -export default class extends Controller { - connect() { - this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js'; - } -} diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js new file mode 100644 index 00000000..daf0abd4 --- /dev/null +++ b/assets/controllers/pages/barcode_scan_controller.js @@ -0,0 +1,86 @@ +import {Controller} from "@hotwired/stimulus"; +import * as ZXing from "@zxing/library"; + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + + static targets = [ "source" ] + + codeReader = null; + + connect() { + console.log('Init Scanner'); + this.codeReader = new ZXing.BrowserMultiFormatReader(); + this.initScanner(); + } + + codeScannedHandler(result, err) { + if (result) { + //@ts-ignore + document.getElementById('scan_dialog_input').value = result.text; + //Submit form + //@ts-ignore + document.getElementById('scan_dialog_form').submit(); + } + if (err && !(err instanceof ZXing.NotFoundException)) { + console.error(err); + //document.getElementById('result').textContent = err + } + } + + initScanner() { + let selectedDeviceId; + + this.codeReader.listVideoInputDevices() + .then((videoInputDevices) => { + if (videoInputDevices.length >= 1) { + const sourceSelect = document.getElementById('sourceSelect'); + + + videoInputDevices.forEach((element) => { + const sourceOption = document.createElement('option'); + sourceOption.text = element.label; + sourceOption.value = element.deviceId; + sourceSelect.appendChild(sourceOption); + }); + + //Try to retrieve last selected webcam... + let last_cam_id = localStorage.getItem('scanner_last_cam_id'); + if (!!last_cam_id) { + //selectedDeviceId = localStorage.getItem('scanner_last_cam_id'); + sourceSelect.value = last_cam_id; + } else { + selectedDeviceId = videoInputDevices[0].deviceId; + } + + sourceSelect.onchange = () => { + //@ts-ignore + selectedDeviceId = sourceSelect.value; + localStorage.setItem('scanner_last_cam_id', selectedDeviceId); + changeHandler(); + }; + + document.getElementById('sourceSelectPanel').classList.remove('d-none'); + document.getElementById('video').classList.remove('d-none'); + document.getElementById('scanner-warning').classList.add('d-none'); + } + + + let changeHandler = () => { + this.codeReader.reset(); + this.codeReader.decodeFromVideoDevice(selectedDeviceId, 'video', (result, err) => this.codeScannedHandler(result, err)); + console.log(`Started continous decode from camera with id ${selectedDeviceId}`) + }; + + //Register Change Src Button + //document.getElementById('changeSrcBtn').addEventListener('click', changeHandler); + + //Try to start logging automatically. + changeHandler(); + + }) + .catch((err) => { + console.error(err) + }) + } +} \ No newline at end of file diff --git a/assets/controllers/pages/label_download_btn_controller.js b/assets/controllers/pages/label_download_btn_controller.js new file mode 100644 index 00000000..dc0f52ec --- /dev/null +++ b/assets/controllers/pages/label_download_btn_controller.js @@ -0,0 +1,8 @@ +import {Controller} from "@hotwired/stimulus"; + +export default class extends Controller +{ + download(event) { + this.element.href = document.getElementById('pdf_preview').data + } +} \ No newline at end of file diff --git a/assets/controllers/pages/reelCalculator_controller.js b/assets/controllers/pages/reelCalculator_controller.js new file mode 100644 index 00000000..325a2887 --- /dev/null +++ b/assets/controllers/pages/reelCalculator_controller.js @@ -0,0 +1,53 @@ +import {Controller} from "@hotwired/stimulus"; +import * as bootbox from "bootbox"; + +export default class extends Controller { + + static values = { + errorMissingValues: String, + errorOuterGreaterInner: String, + } + + + updateReelCalc() { + const dia_inner = document.getElementById('reel_dia_inner').value; + const dia_outer = document.getElementById('reel_dia_outer').value; + const tape_thickness = document.getElementById('reel_tape_thick').value; + const part_distance = document.getElementById('reel_part_distance').value; + + if (dia_inner == "" || dia_outer == "" || tape_thickness == "") { + bootbox.alert(this.errorMissingValuesValue); + return; + } + + if (dia_outer**dia_outer < dia_inner**dia_inner) { + bootbox.alert(this.errorOuterGreaterInnerValue); + return; + } + + const length = Math.PI * (dia_outer * dia_outer - dia_inner * dia_inner ) / (4 * tape_thickness); + + let length_formatted = length.toFixed(2) + ' mm'; + + if (length > 1000) { + length_formatted = (length / 1000).toFixed(2) + ' m'; + } else if (length > 10) { + length_formatted = (length / 10).toFixed(2) + ' cm'; + } + + document.getElementById('result_length').textContent = length_formatted; + + //Skip if no part_distance was given + if (part_distance == "" || part_distance == 0) { + return; + } + + var parts_per_meter = 1 / (part_distance / 1000); + + document.getElementById('result_parts_per_meter').textContent = parts_per_meter.toFixed(2) + ' 1/m'; + + var parts_amount = (length/1000) * parts_per_meter; + + document.getElementById('result_amount').textContent = Math.floor(parts_amount); + } +} \ No newline at end of file diff --git a/assets/controllers/pages/u2f_register_controller.js b/assets/controllers/pages/u2f_register_controller.js new file mode 100644 index 00000000..d807a3aa --- /dev/null +++ b/assets/controllers/pages/u2f_register_controller.js @@ -0,0 +1,10 @@ +import {Controller} from "@hotwired/stimulus"; + +export default class extends Controller +{ + connect() { + this.element.onclick = function() { + window.u2fauth.register(); + } + } +} diff --git a/assets/controllers/turbo/global_reload_controller.js b/assets/controllers/turbo/global_reload_controller.js new file mode 100644 index 00000000..3f03726b --- /dev/null +++ b/assets/controllers/turbo/global_reload_controller.js @@ -0,0 +1,8 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + connect() { + //If we encounter an element with global reload controller, then reload the whole page + window.location.reload(); + } +} \ No newline at end of file diff --git a/assets/controllers/turbo/locale_menu_controller.js b/assets/controllers/turbo/locale_menu_controller.js new file mode 100644 index 00000000..1b2b34a5 --- /dev/null +++ b/assets/controllers/turbo/locale_menu_controller.js @@ -0,0 +1,8 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + connect() { + const menu = document.getElementById('locale-select-menu'); + menu.innerHTML = this.element.innerHTML; + } +} \ No newline at end of file diff --git a/assets/controllers/turbo/title_controller.js b/assets/controllers/turbo/title_controller.js new file mode 100644 index 00000000..b59c182d --- /dev/null +++ b/assets/controllers/turbo/title_controller.js @@ -0,0 +1,12 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + connect() { + //If we encounter an element with this, then change the title of our document according to data-title + this.changeTitle(this.element.dataset.title); + } + + changeTitle(title) { + document.title = title; + } +} \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index 461dfb26..168c9076 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -25,11 +25,11 @@ /* Add padding for fixed header bar */ body { overflow-x: hidden; - position: relative; + /*position: relative;*/ padding-top: 70px; /* Use font size like in BS3 */ - font-size: 14px; - line-height: 1.428; + /*font-size: 14px; + line-height: 1.428;*/ } @media (min-width: 768px) { @@ -69,8 +69,6 @@ body { /*noinspection CssUnknownProperty*/ scrollbar-width: none; } - - } /*noinspection W3CssValidation*/ @@ -242,6 +240,9 @@ showing the sidebar (on devices with md or higher) /**************************************** * Bootstrap extensions *****************************************/ +a.badge { + text-decoration: none; +} .w-fit { width: -moz-fit-content; @@ -420,6 +421,13 @@ table .input-group-btn:last-child>.btn, table .input-group-btn:last-child>.btn-g display: none; } } + +/** + * Enforce white links on selected rows in datatables + */ +table.dataTable > tbody > tr.selected > td > a { + color: white !important; +} /*************************************** * Dropdown with radio buttons ***************************************/ @@ -792,43 +800,6 @@ div.dataTables_wrapper div.dataTables_info { margin: 0; } -/*************************************************** - * Markdown styles - ***************************************************/ -.markdown code { - padding: 2px 4px; - font-size: 90%; - color: #c7254e; - background-color: #f9f2f4; - border-radius: 4px; -} - -.markdown pre { - display: block; - padding: 10px; - margin: 0 0 10px; - font-size: 13px; - line-height: 1.42857143; - color: #333; - word-break: break-all; - word-wrap: break-word; - background-color: #f5f5f5; - border: 1px solid #ccc; - border-radius: 4px; -} - -.markdown img { - max-width: 35%; - vertical-align: middle; -} - -.markdown blockquote { - padding: 10px 10px; - margin: 0 0 10px; - font-size: 18px; - border-left: 5px solid #aaa; -} - .darkmode-layer { z-index: 2020; } @@ -884,4 +855,5 @@ div.dataTables_wrapper div.dataTables_info { ***********************************************/ .typeahead-image { width: 100%; -} \ No newline at end of file +} + diff --git a/assets/css/bootbox_extensions.css b/assets/css/bootbox_extensions.css new file mode 100644 index 00000000..aaf273ab --- /dev/null +++ b/assets/css/bootbox_extensions.css @@ -0,0 +1,27 @@ +.modal-body > .bootbox-close-button { + position: absolute; + top: 0; + right: 0; + padding: 0.5rem 0.75rem; + z-index: 1; +} +.modal .bootbox-close-button { + font-weight: 100; +} + +button.bootbox-close-button { + padding: 0; + background-color: transparent; + border: 0; + -webkit-appearance: none; +} + +.bootbox-close-button { + /* float: right; */ + font-size: 1.40625rem; + font-weight: 600; + line-height: 1; + color: #000; + text-shadow: none; + opacity: .5; +} \ No newline at end of file diff --git a/assets/css/markdown.css b/assets/css/markdown.css new file mode 100644 index 00000000..ec7abc5a --- /dev/null +++ b/assets/css/markdown.css @@ -0,0 +1,53 @@ +/** + * Customization for markdown output + */ + +.markdown .text-tiny { + font-size: .7em; +} + +.markdown .text-small { + font-size: .85em; +} + +.markdown .text-big { + font-size: 1.4em; +} + +.markdown .text-huge { + font-size: 1.8em; +} + +.markdown code { + padding: 2px 4px; + font-size: 90%; + color: #c7254e; + background-color: #f9f2f4; + border-radius: 4px; +} + +.markdown pre { + display: block; + padding: 10px; + margin: 0 0 10px; + font-size: 13px; + line-height: 1.42857143; + color: #333; + word-break: break-all; + word-wrap: break-word; + background-color: #f5f5f5; + border: 1px solid #ccc; + border-radius: 4px; +} + +.markdown img { + max-width: 35%; + vertical-align: middle; +} + +.markdown blockquote { + padding: 10px 10px; + margin: 0 0 10px; + font-size: 18px; + border-left: 5px solid #aaa; +} \ No newline at end of file diff --git a/assets/css/selectpicker_extensions.css b/assets/css/selectpicker_extensions.css new file mode 100644 index 00000000..925ab592 --- /dev/null +++ b/assets/css/selectpicker_extensions.css @@ -0,0 +1,14 @@ +/*********************************************** + * Special level whitespace characters that only show up when inside a bootstrap-select dropdown + ***********************************************/ +.dropdown-item span.picker-level::after { + content: "\00a0\00a0\00a0"; /* 3 spaces */ +} + +/** Bootstrap-select Hide on Selected element */ +.picker-hs { + display: none; +} +.dropdown-item .picker-hs { + display: inherit; +} \ No newline at end of file diff --git a/assets/css/tagsinput.css b/assets/css/tagsinput.css deleted file mode 100644 index e3ff2c34..00000000 --- a/assets/css/tagsinput.css +++ /dev/null @@ -1,64 +0,0 @@ -/* - * bootstrap-tagsinput v0.8.0 - * Modified by Jan Böhmer 2019 - * (Added some margin between the tags. - */ - -.bootstrap-tagsinput { - background-color: #fff; - border: 1px solid #ccc; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - display: inline-block; - padding: 4px 6px; - color: #555; - vertical-align: middle; - border-radius: 4px; - width: 100%; - line-height: 22px; - cursor: text; -} -.bootstrap-tagsinput input { - border: none; - box-shadow: none; - outline: none; - background-color: transparent; - padding: 0 6px; - margin: 0; - width: auto; - max-width: inherit; -} -.bootstrap-tagsinput.form-control input::-moz-placeholder { - color: #777; - opacity: 1; -} -.bootstrap-tagsinput.form-control input:-ms-input-placeholder { - color: #777; -} -.bootstrap-tagsinput.form-control input::-webkit-input-placeholder { - color: #777; -} -.bootstrap-tagsinput input:focus { - border: none; - box-shadow: none; -} -.bootstrap-tagsinput .badge { - margin: 2px 0.2em; - padding:5px 8px; -} -.bootstrap-tagsinput .badge [data-role="remove"] { - margin-left: 8px; - cursor: pointer; -} -.bootstrap-tagsinput .badge [data-role="remove"]:after { - content: "×"; - padding: 0px 4px; - background-color:rgba(0, 0, 0, 0.1); - border-radius:50%; - font-size:13px -} -.bootstrap-tagsinput .badge [data-role="remove"]:hover:after { - - background-color:rgba(0, 0, 0, 0.62);} -.bootstrap-tagsinput .badge [data-role="remove"]:hover:active { - box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); -} diff --git a/assets/css/tom-select_extensions.css b/assets/css/tom-select_extensions.css new file mode 100644 index 00000000..6b57888c --- /dev/null +++ b/assets/css/tom-select_extensions.css @@ -0,0 +1,4 @@ +.tagsinput.ts-wrapper.multi .ts-control > div { + background: var(--bs-secondary); + color: var(--bs-white); +} \ No newline at end of file diff --git a/assets/js/app.js b/assets/js/app.js index 2eb8508b..df469a78 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -17,116 +17,24 @@ * along with this program. If not, see . */ -/* - * Welcome to your app's main JavaScript file! - * - * We recommend including the built version of this JavaScript file - * (and its CSS file) in your base layout (base.html.twig). - */ +// Main CSS files +import '../css/app.css'; -// any CSS you require will output into a single css file (app.css in this case) - -require('../css/app.css'); +// start the Stimulus application +import '../bootstrap'; // Need jQuery? Install it with "yarn add jquery", then uncomment to require it. const $ = require('jquery'); //Only include javascript - - - import '@fortawesome/fontawesome-free/css/all.css' -import 'datatables.net-bs4/css/dataTables.bootstrap4.css' -import 'datatables.net-buttons-bs4/css/buttons.bootstrap4.css' -import 'datatables.net-fixedheader-bs4/css/fixedHeader.bootstrap4.css' -import 'datatables.net-select-bs4/css/select.bootstrap4.css' -import 'bootstrap-select/dist/css/bootstrap-select.css' - -import "patternfly-bootstrap-treeview/src/css/bootstrap-treeview.css" - -import "bootstrap-fileinput/css/fileinput.css" - require('bootstrap'); -//require( 'jszip' ); -//#require( 'pdfmake' ); -require( 'datatables.net-bs4' ); -require( 'datatables.net-buttons-bs4' ); -require( 'datatables.net-buttons/js/buttons.colVis.js' ); -//require( 'datatables.net-buttons/js/buttons.html5.js' ); -//require( 'datatables.net-buttons/js/buttons.print.js' ); -require( 'datatables.net-fixedheader-bs4' ); -require( 'datatables.net-select-bs4' ); -require('datatables.net-colreorder-bs4'); -require('datatables.net-responsive-bs4'); -require('datatables.net-responsive-bs4/css/responsive.bootstrap4.css'); -require('bootstrap-select'); -require('jquery-form'); -require('corejs-typeahead/dist/typeahead.bundle.min'); -window.Bloodhound = require('corejs-typeahead/dist/bloodhound.js'); +import "./error_handler"; +import "./tab_remember"; +import "./register_events"; +import "./tristate_checkboxes"; //Define jquery globally -window.$ = window.jQuery = require("jquery"); - -require('patternfly-bootstrap-treeview/src/js/bootstrap-treeview'); - -require('bootstrap-fileinput'); - -require('./datatables.js'); - -window.bootbox = require('bootbox'); - -require("marked"); -window.DOMPurify = require("dompurify"); - -// Includes required for tag input -require('./tagsinput.js'); -require('../css/tagsinput.css'); - -//Tristate checkbox support -require('./jquery.tristate.js'); - -require('darkmode-js'); - -//Equation rendering -require('katex'); -window.renderMathInElement = require('katex/contrib/auto-render/auto-render').default; -import 'katex/dist/katex.css'; - -window.ClipboardJS = require('clipboard'); - -require('../ts_src/ajax_ui'); -import {ajaxUI} from "../ts_src/ajax_ui"; - -window.ajaxUI = ajaxUI; - -//Require all events; -require('../ts_src/event_listeners'); - - -//Register darkmode (we must do it here, TS does not support ES6 constructor... -try { - //The browser needs to support mix blend mode - if(typeof window.getComputedStyle(document.body).mixBlendMode !== 'undefined') { - const darkmode = new Darkmode(); - - $(document).on("ajaxUI:start ajaxUI:reload", function () { - //Show darkmode toggle to user - $('#toggleDarkmodeContainer, #toggleDarkmodeSeparator').removeAttr('hidden'); - $('#toggleDarkmode').prop('checked', darkmode.isActivated()); - $('#toggleDarkmode').change(function () { - darkmode.toggle(); - }); - }); - } -} catch (e) { - //Ignore all errors (for compatibiltiy with old browsers) -} - -//Start AjaxUI AFTER all event has been registered -$(document).ready(ajaxUI.start()); - - - -//console.log('Hello Webpack Encore! Edit me in assets/js/app.js'); \ No newline at end of file +window.$ = window.jQuery = require("jquery") \ No newline at end of file diff --git a/assets/js/error_handler.js b/assets/js/error_handler.js new file mode 100644 index 00000000..573a0915 --- /dev/null +++ b/assets/js/error_handler.js @@ -0,0 +1,81 @@ +import * as bootbox from "bootbox"; + +/** + * If this class is imported the user is shown an error dialog if he calls an page via Turbo and an error is responded. + * @type {ErrorHandlerHelper} + */ +class ErrorHandlerHelper { + constructor() { + console.log('Error Handler registered'); + + const content = document.getElementById('content'); + content.addEventListener('turbo:before-fetch-response', (event) => this.handleError(event)); + } + + handleError(event) { + const fetchResponse = event.detail.fetchResponse; + const response = fetchResponse.response; + + //Ignore aborted requests. + if (response.statusText === 'abort' || response.status == 0) { + return; + } + + //Ignore status 422 as this means a symfony validation error occured and we need to show it to user. This is no (unexpected) error. + if (response.status == 422) { + return; + } + + if(fetchResponse.failed) { + //Create error text + let title = response.statusText + ' (Status ' + response.status + ')'; + + /** + switch(response.status) { + case 500: + title = 'Internal Server Error!'; + break; + case 404: + title = "Site not found!"; + break; + case 403: + title = "Permission denied!"; + break; + } **/ + + const alert = bootbox.alert( + { + size: 'large', + message: function() { + let url = fetchResponse.location.toString(); + let msg = `Error calling ${url}. `; + msg += 'Try to reload the page or contact the administrator if this error persists.' + + msg += '

' + 'View details' + ""; + msg += "
"; + + return msg; + }, + title: title, + callback: function () { + //Remove blur + $('#content').removeClass('loading-content'); + } + + }); + + //@ts-ignore + alert.init(function (){ + response.text().then( (html) => { + var dstFrame = document.getElementById('error-iframe'); + //@ts-ignore + var dstDoc = dstFrame.contentDocument || dstFrame.contentWindow.document; + dstDoc.write(html) + dstDoc.close(); + }); + }); + } + } +} + +export default new ErrorHandlerHelper(); \ No newline at end of file diff --git a/assets/js/datatables.js b/assets/js/lib/datatables.js similarity index 62% rename from assets/js/datatables.js rename to assets/js/lib/datatables.js index 43b568cf..5c94799a 100644 --- a/assets/js/datatables.js +++ b/assets/js/lib/datatables.js @@ -43,7 +43,7 @@ return new Promise((fulfill, reject) => { // Perform initial load - $.ajax(config.url, { + $.ajax(typeof config.url === 'function' ? config.url(null) : config.url, { method: config.method, data: { _dt: config.name, @@ -53,15 +53,17 @@ var baseState; // Merge all options from different sources together and add the Ajax loader - var dtOpts = $.extend({}, data.options, config.options, options, persistOptions, { + var dtOpts = $.extend({}, data.options, typeof config.options === 'function' ? {} : config.options, options, persistOptions, { ajax: function (request, drawCallback, settings) { if (data) { data.draw = request.draw; drawCallback(data); data = null; - if (Object.keys(state).length && dt.state != null) { - var merged = $.extend(true, {}, dt.state(), state); - dt + if (Object.keys(state).length) { + var api = new $.fn.dataTable.Api( settings ); + var merged = $.extend(true, {}, api.state(), state); + + api .order(merged.order) .search(merged.search.search) .page.len(merged.length) @@ -70,7 +72,7 @@ } } else { request._dt = config.name; - $.ajax(config.url, { + $.ajax(typeof config.url === 'function' ? config.url(dt) : config.url, { method: config.method, data: request }).done(function(data) { @@ -80,6 +82,10 @@ } }); + if (typeof config.options === 'function') { + dtOpts = config.options(dtOpts); + } + root.html(data.template); dt = $('table', root).DataTable(dtOpts); if (config.state !== 'none') { @@ -122,6 +128,80 @@ url: window.location.origin + window.location.pathname }; + /** + * Server-side export. + */ + $.fn.initDataTables.exportBtnAction = function(exporterName, settings) { + settings = $.extend({}, $.fn.initDataTables.defaults, settings); + + return function(e, dt) { + const params = $.param($.extend({}, dt.ajax.params(), {'_dt': settings.name, '_exporter': exporterName})); + + // Credit: https://stackoverflow.com/a/23797348 + const xhr = new XMLHttpRequest(); + xhr.open(settings.method, settings.method === 'GET' ? (settings.url + '?' + params) : settings.url, true); + xhr.responseType = 'arraybuffer'; + xhr.onload = function () { + if (this.status === 200) { + let filename = ""; + const disposition = xhr.getResponseHeader('Content-Disposition'); + if (disposition && disposition.indexOf('attachment') !== -1) { + const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; + const matches = filenameRegex.exec(disposition); + if (matches != null && matches[1]) { + filename = matches[1].replace(/['"]/g, ''); + } + } + + const type = xhr.getResponseHeader('Content-Type'); + + let blob; + if (typeof File === 'function') { + try { + blob = new File([this.response], filename, { type: type }); + } catch (e) { /* Edge */ } + } + + if (typeof blob === 'undefined') { + blob = new Blob([this.response], { type: type }); + } + + if (typeof window.navigator.msSaveBlob !== 'undefined') { + // IE workaround for "HTML7007: One or more blob URLs were revoked by closing the blob for which they were created. These URLs will no longer resolve as the data backing the URL has been freed." + window.navigator.msSaveBlob(blob, filename); + } + else { + const URL = window.URL || window.webkitURL; + const downloadUrl = URL.createObjectURL(blob); + + if (filename) { + // use HTML5 a[download] attribute to specify filename + const a = document.createElement("a"); + // safari doesn't support this yet + if (typeof a.download === 'undefined') { + window.location = downloadUrl; + } + else { + a.href = downloadUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + } + } + else { + window.location = downloadUrl; + } + + setTimeout(function() { URL.revokeObjectURL(downloadUrl); }, 100); // cleanup + } + } + }; + + xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); + xhr.send(settings.method === 'POST' ? params : null); + } + }; + /** * Convert a querystring to a proper array - reverses $.param */ @@ -182,4 +262,4 @@ return obj; } -}($)); +}(jQuery)); diff --git a/assets/js/jquery.tristate.js b/assets/js/lib/jquery.tristate.js similarity index 100% rename from assets/js/jquery.tristate.js rename to assets/js/lib/jquery.tristate.js diff --git a/assets/js/register_events.js b/assets/js/register_events.js new file mode 100644 index 00000000..2f6b742d --- /dev/null +++ b/assets/js/register_events.js @@ -0,0 +1,104 @@ +'use strict'; + +class RegisterEventHelper { + constructor() { + this.registerTooltips(); + + this.registerSpecialCharInput(); + } + + registerLoadHandler(fn) { + document.addEventListener('turbo:render', fn); + document.addEventListener('turbo:load', fn); + } + + registerTooltips() { + this.registerLoadHandler(() => { + $(".tooltip").remove(); + //Exclude dropdown buttons from tooltips, otherwise we run into endless errors from bootstrap (bootstrap.esm.js:614 Bootstrap doesn't allow more than one instance per element. Bound instance: bs.dropdown.) + $('a[title], button[title]:not([data-bs-toggle="dropdown"]), span[title], h6[title], h3[title], i.fas[title]') + //@ts-ignore + .tooltip("hide").tooltip({container: "body", placement: "auto", boundary: 'window'}); + }); + } + + registerSpecialCharInput() { + this.registerLoadHandler(() => { + //@ts-ignore + $("input[type=text], input[type=search]").unbind("keydown").keydown(function (event) { + let greek = event.altKey; + + let greek_char = ""; + if (greek){ + switch(event.key) { + case "w": //Omega + greek_char = '\u2126'; + break; + case "u": + case "m": //Micro + greek_char = "\u00B5"; + break; + case "p": //Phi + greek_char = "\u03C6"; + break; + case "a": //Alpha + greek_char = "\u03B1"; + break; + case "b": //Beta + greek_char = "\u03B2"; + break; + case "c": //Gamma + greek_char = "\u03B3"; + break; + case "d": //Delta + greek_char = "\u03B4"; + break; + case "l": //Pound + greek_char = "\u00A3"; + break; + case "y": //Yen + greek_char = "\u00A5"; + break; + case "o": //Yen + greek_char = "\u00A4"; + break; + case "1": //Sum symbol + greek_char = "\u2211"; + break; + case "2": //Integral + greek_char = "\u222B"; + break; + case "3": //Less-than or equal + greek_char = "\u2264"; + break; + case "4": //Greater than or equal + greek_char = "\u2265"; + break; + case "5": //PI + greek_char = "\u03c0"; + break; + case "q": //Copyright + greek_char = "\u00A9"; + break; + case "e": //Euro + greek_char = "\u20AC"; + break; + } + + if(greek_char=="") return; + + let $txt = $(this); + //@ts-ignore + let caretPos = $txt[0].selectionStart; + let textAreaTxt = $txt.val().toString(); + $txt.val(textAreaTxt.substring(0, caretPos) + greek_char + textAreaTxt.substring(caretPos) ); + + } + }); + //@ts-ignore + this.greek_once = true; + }) + } +} + +export default new RegisterEventHelper(); \ No newline at end of file diff --git a/assets/js/tab_remember.js b/assets/js/tab_remember.js new file mode 100644 index 00000000..b0697393 --- /dev/null +++ b/assets/js/tab_remember.js @@ -0,0 +1,101 @@ +"use strict"; + +import {Tab} from "bootstrap"; +import tab from "bootstrap/js/src/tab"; + +/** + * This listener keeps track of which tab is currently selected (using hash and localstorage) and will try to open + * that tab on reload. That means that if the user changes something, he does not have to switch back to the tab + * where he was before submit. + */ +class TabRememberHelper { + constructor() { + document.addEventListener("turbo:load", this.onLoad.bind(this)); + + document.addEventListener("turbo:frame-render", this.handleSymfonyValidationErrors.bind(this)); + + //Capture is important here, as invalid events normally does not bubble + document.addEventListener("invalid", this.onInvalid.bind(this), {capture: true}); + } + + handleSymfonyValidationErrors(event) { + const responseCode = event.detail.fetchResponse.response.status; + + //We only care about 422 (symfony validation error) + if(responseCode !== 422) { + return; + } + + //Find the first offending element and show it + //Symfony validation errors can occur on multiple types + const inputErrors = document.getElementsByClassName('is-invalid'); + const blockErrors = document.getElementsByClassName('form-error-message'); + const merged = [...inputErrors, ...blockErrors]; + + const first_element = merged[0] ?? null; + if(first_element) { + this.revealElementOnTab(first_element); + } + + debugger; + } + + /** + * This functions is called when the browser side input validation fails on an input, jump to the tab to show this up + * @param event + */ + onInvalid(event) { + this.revealElementOnTab(event.target); + } + + revealElementOnTab(element) { + let parent = element.closest('.tab-pane'); + + //Iterate over each parent tab and show it + while(parent) { + //Invoker can either be a button or a element + let tabInvoker = document.querySelector("button[data-content='#" + parent.id + "']") + ?? document.querySelector("a[href='#" + parent.id + "']"); + Tab.getOrCreateInstance(tabInvoker).show(); + + parent = parent.parentElement.closest('.tab-pane'); + } + } + + onLoad(event) { + //Determine which tab should be shown (use hash if specified, otherwise use localstorage) + let activeTab = null; + if (location.hash) { + activeTab = document.querySelector('[href=\'' + location.hash + '\']'); + } else if (localStorage.getItem('activeTab')) { + activeTab = document.querySelector('[href="' + localStorage.getItem('activeTab') + '"]'); + } + + if (activeTab) { + + //Reveal our tab selector (needed for nested tabs) + this.revealElementOnTab(activeTab); + + //Finally show the active tab itself + Tab.getOrCreateInstance(activeTab).show(); + } + + //Register listener for tab change + document.addEventListener('shown.bs.tab', this.onTabChange.bind(this)); + } + + onTabChange(event) { + const tab = event.target; + + let tab_name = tab.getAttribute('href') + if (history.replaceState) { + history.replaceState(null, null, tab_name) + } else { + location.hash = tab_name + } + localStorage.setItem('activeTab', tab_name) + } + +} + +export default new TabRememberHelper(); \ No newline at end of file diff --git a/assets/js/tagsinput.js b/assets/js/tagsinput.js deleted file mode 100644 index 1c063255..00000000 --- a/assets/js/tagsinput.js +++ /dev/null @@ -1,687 +0,0 @@ -/* - * bootstrap-tagsinput v0.8.0 - * - */ - -(function ($) { - "use strict"; - - var defaultOptions = { - tagClass: function(item) { - return 'badge badge-info'; - }, - focusClass: 'focus', - itemValue: function(item) { - return item ? item.toString() : item; - }, - itemText: function(item) { - return this.itemValue(item); - }, - itemTitle: function(item) { - return null; - }, - freeInput: true, - addOnBlur: true, - maxTags: undefined, - maxChars: undefined, - confirmKeys: [13, 44], - delimiter: ',', - delimiterRegex: null, - cancelConfirmKeysOnEmpty: false, - onTagExists: function(item, $tag) { - $tag.hide().fadeIn(); - }, - trimValue: false, - allowDuplicates: false, - triggerChange: true - }; - - /** - * Constructor function - */ - function TagsInput(element, options) { - this.isInit = true; - this.itemsArray = []; - - this.$element = $(element); - this.$element.addClass('sr-only'); - - this.isSelect = (element.tagName === 'SELECT'); - this.multiple = (this.isSelect && element.hasAttribute('multiple')); - this.objectItems = options && options.itemValue; - this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : ''; - this.inputSize = Math.max(1, this.placeholderText.length); - - this.$container = $('
'); - this.$input = $('').appendTo(this.$container); - - this.$element.before(this.$container); - - this.build(options); - this.isInit = false; - } - - TagsInput.prototype = { - constructor: TagsInput, - - /** - * Adds the given item as a new tag. Pass true to dontPushVal to prevent - * updating the elements val() - */ - add: function(item, dontPushVal, options) { - var self = this; - - if (self.options.maxTags && self.itemsArray.length >= self.options.maxTags) - return; - - // Ignore falsey values, except false - if (item !== false && !item) - return; - - // Trim value - if (typeof item === "string" && self.options.trimValue) { - item = $.trim(item); - } - - // Throw an error when trying to add an object while the itemValue option was not set - if (typeof item === "object" && !self.objectItems) - throw("Can't add objects when itemValue option is not set"); - - // Ignore strings only containg whitespace - if (item.toString().match(/^\s*$/)) - return; - - // If SELECT but not multiple, remove current tag - if (self.isSelect && !self.multiple && self.itemsArray.length > 0) - self.remove(self.itemsArray[0]); - - if (typeof item === "string" && this.$element[0].tagName === 'INPUT') { - var delimiter = (self.options.delimiterRegex) ? self.options.delimiterRegex : self.options.delimiter; - var items = item.split(delimiter); - if (items.length > 1) { - for (var i = 0; i < items.length; i++) { - this.add(items[i], true); - } - - if (!dontPushVal) - self.pushVal(self.options.triggerChange); - return; - } - } - - var itemValue = self.options.itemValue(item), - itemText = self.options.itemText(item), - tagClass = self.options.tagClass(item), - itemTitle = self.options.itemTitle(item); - - // Ignore items allready added - var existing = $.grep(self.itemsArray, function(item) { return self.options.itemValue(item) === itemValue; } )[0]; - if (existing && !self.options.allowDuplicates) { - // Invoke onTagExists - if (self.options.onTagExists) { - var $existingTag = $(".badge", self.$container).filter(function() { return $(this).data("item") === existing; }); - self.options.onTagExists(item, $existingTag); - } - return; - } - - // if length greater than limit - if (self.items().toString().length + item.length + 1 > self.options.maxInputLength) - return; - - // raise beforeItemAdd arg - var beforeItemAddEvent = $.Event('beforeItemAdd', { item: item, cancel: false, options: options}); - self.$element.trigger(beforeItemAddEvent); - if (beforeItemAddEvent.cancel) - return; - - // register item in internal array and map - self.itemsArray.push(item); - - // add a tag element - - var $tag = $('' + htmlEncode(itemText) + ''); - $tag.data('item', item); - self.findInputWrapper().before($tag); - - // Check to see if the tag exists in its raw or uri-encoded form - var optionExists = ( - $('option[value="' + encodeURIComponent(itemValue) + '"]', self.$element).length || - $('option[value="' + htmlEncode(itemValue) + '"]', self.$element).length - ); - - // add