From 8bb26c3a57306374f577580d214381f4c8445d19 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 14 Aug 2026 14:16:33 +0200 Subject: [PATCH 1/3] Extract file stage icons into separate view Prepares reusing the spinner, alert and bell icons of file stage items as thumbnail placeholders. --- .../stylesheets/pageflow/editor/base.scss | 1 + .../pageflow/editor/file_stage_icons.scss | 17 ++++ .../pageflow/editor/file_stages.scss | 12 --- .../editor/views/FileStageIconView-spec.js | 81 +++++++++++++++++++ .../editor/views/FileStageItemView-spec.js | 52 +----------- package/src/editor/index.js | 1 + .../src/editor/templates/fileStageIcon.jst | 11 +++ .../src/editor/templates/fileStageItem.jst | 14 +--- package/src/editor/views/FileStageIconView.js | 36 +++++++++ package/src/editor/views/FileStageItemView.js | 21 ++--- 10 files changed, 155 insertions(+), 91 deletions(-) create mode 100644 app/assets/stylesheets/pageflow/editor/file_stage_icons.scss create mode 100644 package/spec/editor/views/FileStageIconView-spec.js create mode 100644 package/src/editor/templates/fileStageIcon.jst create mode 100644 package/src/editor/views/FileStageIconView.js diff --git a/app/assets/stylesheets/pageflow/editor/base.scss b/app/assets/stylesheets/pageflow/editor/base.scss index c5d1a0f2a9..9e623dc874 100644 --- a/app/assets/stylesheets/pageflow/editor/base.scss +++ b/app/assets/stylesheets/pageflow/editor/base.scss @@ -79,6 +79,7 @@ @import "./file_meta_data"; @import "./file_meta_data_overlay"; @import "./file_preview"; + @import "./file_stage_icons"; @import "./file_stages"; @import "./file_thumbnails"; @import "./file_settings_dialog"; diff --git a/app/assets/stylesheets/pageflow/editor/file_stage_icons.scss b/app/assets/stylesheets/pageflow/editor/file_stage_icons.scss new file mode 100644 index 0000000000..697ce45c47 --- /dev/null +++ b/app/assets/stylesheets/pageflow/editor/file_stage_icons.scss @@ -0,0 +1,17 @@ +.file_stage_icon { + display: block; + width: 100%; + height: 100%; + + &-spinner { + @include animation(spin 1s linear infinite); + } + + &-spinner, + &-alert, + &-bell { + display: block; + width: 100%; + height: 100%; + } +} diff --git a/app/assets/stylesheets/pageflow/editor/file_stages.scss b/app/assets/stylesheets/pageflow/editor/file_stages.scss index b20011de97..53940e8412 100644 --- a/app/assets/stylesheets/pageflow/editor/file_stages.scss +++ b/app/assets/stylesheets/pageflow/editor/file_stages.scss @@ -34,18 +34,6 @@ } } - &-spinner { - @include animation(spin 1s linear infinite); - } - - &-spinner, - &-alert, - &-bell { - display: block; - width: 100%; - height: 100%; - } - .percent { color: var(--ui-on-surface-color-light); font-size: 12px; diff --git a/package/spec/editor/views/FileStageIconView-spec.js b/package/spec/editor/views/FileStageIconView-spec.js new file mode 100644 index 0000000000..2e828e4b5b --- /dev/null +++ b/package/spec/editor/views/FileStageIconView-spec.js @@ -0,0 +1,81 @@ +import Backbone from 'backbone'; + +import {FileStage, FileStageIconView} from 'pageflow/editor'; + +import {renderBackboneView as render} from 'pageflow/testHelpers'; + +describe('FileStageIconView', () => { + function stage(state) { + return new FileStage({name: 'encoding'}, { + file: new Backbone.Model({state}), + activeStates: ['encoding'], + finishedStates: ['encoded'], + failedStates: ['encoding_failed'], + actionRequiredStates: ['waiting_for_confirmation'] + }); + } + + function icons(view) { + function shown(name) { + var element = view.$el.find('.file_stage_icon-' + name)[0]; + + return !!element && element.style.display !== 'none'; + } + + return { + spinner: shown('spinner'), + alert: shown('alert'), + bell: shown('bell') + }; + } + + it('shows spinner while stage is active', () => { + const view = new FileStageIconView({model: stage('encoding')}); + + render(view); + + expect(icons(view)).toEqual({spinner: true, alert: false, bell: false}); + }); + + it('shows spinner while stage is pending', () => { + const view = new FileStageIconView({model: stage('uploading')}); + + render(view); + + expect(icons(view)).toEqual({spinner: true, alert: false, bell: false}); + }); + + it('shows alert icon when stage failed', () => { + const view = new FileStageIconView({model: stage('encoding_failed')}); + + render(view); + + expect(icons(view)).toEqual({spinner: false, alert: true, bell: false}); + }); + + it('shows bell icon when stage requires action', () => { + const view = new FileStageIconView({model: stage('waiting_for_confirmation')}); + + render(view); + + expect(icons(view)).toEqual({spinner: false, alert: false, bell: true}); + }); + + it('updates icon when state changes', () => { + const model = stage('encoding'); + const view = new FileStageIconView({model}); + + render(view); + model.file.set('state', 'encoding_failed'); + + expect(icons(view)).toEqual({spinner: false, alert: true, bell: false}); + }); + + it('hides icon from screen readers', () => { + const view = new FileStageIconView({model: stage('encoding')}); + + render(view); + + expect(view.el.getAttribute('aria-hidden')).toEqual('true'); + }); +}); diff --git a/package/spec/editor/views/FileStageItemView-spec.js b/package/spec/editor/views/FileStageItemView-spec.js index a07387bed4..0450afef55 100644 --- a/package/spec/editor/views/FileStageItemView-spec.js +++ b/package/spec/editor/views/FileStageItemView-spec.js @@ -15,59 +15,11 @@ describe('FileStageItemView', () => { }); } - function icons(view) { - function shown(name) { - var element = view.$el.find('.file_stage_item-' + name)[0]; - - return !!element && element.style.display !== 'none'; - } - - return { - spinner: shown('spinner'), - alert: shown('alert'), - bell: shown('bell') - }; - } - - it('shows spinner while stage is active', () => { - const view = new FileStageItemView({model: stage('encoding')}); - - render(view); - - expect(icons(view)).toEqual({spinner: true, alert: false, bell: false}); - }); - - it('shows spinner while stage is pending', () => { - const view = new FileStageItemView({model: stage('uploading')}); - - render(view); - - expect(icons(view)).toEqual({spinner: true, alert: false, bell: false}); - }); - - it('shows alert icon when stage failed', () => { + it('renders icon for stage', () => { const view = new FileStageItemView({model: stage('encoding_failed')}); render(view); - expect(icons(view)).toEqual({spinner: false, alert: true, bell: false}); - }); - - it('shows bell icon when stage requires action', () => { - const view = new FileStageItemView({model: stage('waiting_for_confirmation')}); - - render(view); - - expect(icons(view)).toEqual({spinner: false, alert: false, bell: true}); - }); - - it('updates icon when state changes', () => { - const model = stage('encoding'); - const view = new FileStageItemView({model}); - - render(view); - model.file.set('state', 'encoding_failed'); - - expect(icons(view)).toEqual({spinner: false, alert: true, bell: false}); + expect(view.$el.find('.file_stage_icon-alert')[0].style.display).not.toEqual('none'); }); }); diff --git a/package/src/editor/index.js b/package/src/editor/index.js index 18aa745762..a366ab346c 100644 --- a/package/src/editor/index.js +++ b/package/src/editor/index.js @@ -174,6 +174,7 @@ export * from './views/mixins/listHighlighting'; export * from './views/mixins/loadable'; export * from './views/mixins/dialogView'; export * from './views/mixins/modelLifecycleTrackingView'; +export * from './views/FileStageIconView'; export * from './views/FileStageItemView'; export * from './views/ConfirmUploadView'; export * from './views/ChooseImporterView'; diff --git a/package/src/editor/templates/fileStageIcon.jst b/package/src/editor/templates/fileStageIcon.jst new file mode 100644 index 0000000000..24cbf87a4f --- /dev/null +++ b/package/src/editor/templates/fileStageIcon.jst @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/package/src/editor/templates/fileStageItem.jst b/package/src/editor/templates/fileStageItem.jst index 46593efb55..0b071186c6 100644 --- a/package/src/editor/templates/fileStageItem.jst +++ b/package/src/editor/templates/fileStageItem.jst @@ -1,16 +1,4 @@ - +

diff --git a/package/src/editor/views/FileStageIconView.js b/package/src/editor/views/FileStageIconView.js new file mode 100644 index 0000000000..c0855fb8b5 --- /dev/null +++ b/package/src/editor/views/FileStageIconView.js @@ -0,0 +1,36 @@ +import Marionette from 'backbone.marionette'; + +import template from '../templates/fileStageIcon.jst'; + +export const FileStageIconView = Marionette.ItemView.extend({ + tagName: 'span', + className: 'file_stage_icon', + template, + + attributes: { + 'aria-hidden': 'true' + }, + + ui: { + spinner: '.file_stage_icon-spinner', + alert: '.file_stage_icon-alert', + bell: '.file_stage_icon-bell' + }, + + modelEvents: { + 'change': 'update' + }, + + onRender: function() { + this.update(); + }, + + update: function() { + var failed = !!this.model.get('failed'); + var actionRequired = !!this.model.get('action_required'); + + this.ui.spinner.toggle(!failed && !actionRequired); + this.ui.alert.toggle(failed); + this.ui.bell.toggle(actionRequired); + } +}); diff --git a/package/src/editor/views/FileStageItemView.js b/package/src/editor/views/FileStageItemView.js index 5e6f2a0616..0c20b54ace 100644 --- a/package/src/editor/views/FileStageItemView.js +++ b/package/src/editor/views/FileStageItemView.js @@ -2,6 +2,7 @@ import I18n from 'i18n-js'; import Marionette from 'backbone.marionette'; import template from '../templates/fileStageItem.jst'; +import {FileStageIconView} from './FileStageIconView'; export const FileStageItemView = Marionette.ItemView.extend({ tagName: 'li', @@ -9,13 +10,10 @@ export const FileStageItemView = Marionette.ItemView.extend({ template, ui: { + icon: '.file_stage_item-icon', description: '.description', percent: '.percent', - errorMessage: '.error_message', - - spinner: '.file_stage_item-spinner', - alert: '.file_stage_item-alert', - bell: '.file_stage_item-bell' + errorMessage: '.error_message' }, modelEvents: { @@ -23,6 +21,8 @@ export const FileStageItemView = Marionette.ItemView.extend({ }, onRender: function() { + this.appendSubview(new FileStageIconView({model: this.model}), {to: this.ui.icon}); + this.update(); this.$el.addClass(this.model.get('name')); @@ -53,17 +53,6 @@ export const FileStageItemView = Marionette.ItemView.extend({ this.$el.toggleClass('finished', this.model.get('finished')); this.$el.toggleClass('failed', this.model.get('failed')); this.$el.toggleClass('action_required', this.model.get('action_required')); - - this.updateIcon(); - }, - - updateIcon: function() { - var failed = !!this.model.get('failed'); - var actionRequired = !!this.model.get('action_required'); - - this.ui.spinner.toggle(!failed && !actionRequired); - this.ui.alert.toggle(failed); - this.ui.bell.toggle(actionRequired); }, _translatedErrorMessage: function() { From 48e0377657c41ba52a07432fad6069f965a2165b Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 14 Aug 2026 14:21:12 +0200 Subject: [PATCH 2/3] Use file stage icons as thumbnail placeholders Thumbnails of files that are not ready yet now display the same spinner, alert and bell icons as file stage items instead of a separate set of icon font pictograms per processing stage. Pictograms of file types that do not support thumbnails and of unset files remain unchanged. --- .../pageflow/editor/file_thumbnails.scss | 50 ++++++------- .../editor/views/FileThumbnailView-spec.js | 70 ++++++++++++++++++- package/src/editor/views/FileThumbnailView.js | 34 ++++----- 3 files changed, 105 insertions(+), 49 deletions(-) diff --git a/app/assets/stylesheets/pageflow/editor/file_thumbnails.scss b/app/assets/stylesheets/pageflow/editor/file_thumbnails.scss index 8b7bd67657..f1ea365fbc 100644 --- a/app/assets/stylesheets/pageflow/editor/file_thumbnails.scss +++ b/app/assets/stylesheets/pageflow/editor/file_thumbnails.scss @@ -9,15 +9,35 @@ inset: 0; } + // Covers thumbnail image and pictogram while the file is being + // processed. + &-stage_icon { + position: absolute; + inset: 0; + + display: flex; + align-items: center; + justify-content: center; + + background-color: var(--ui-on-surface-color-light-solid); + color: var(--ui-on-primary-color); + + &:empty { + display: none; + } + } + + .file_stage_icon { + width: 40%; + height: 40%; + } + .pictogram { width: 100%; height: 100%; - } - .pictogram { background-color: var(--ui-on-surface-color-light-solid); @include background-icon-center($color: var(--ui-on-primary-color), $font-size: 30px); - @include background-icon-animation(blink); &.audio { @include note-icon; @@ -27,38 +47,14 @@ @include fa-cloud-download-icon; } - &.uploading { - @include up-bold-icon; - } - - &.fetching_meta_data { - @include clipboard-icon; - } - - &.processing, - &.encoding { - @include cog-icon; - } - &.empty { @include picture-icon; - @include background-icon-animation(none); - } - - &.action_required { - @include bell-icon; } } - div.pictogram.failed { - @include attention-icon; - @include background-icon-animation(none); - } - &.ready { .pictogram { display: none; - @include background-icon-animation(none); } } diff --git a/package/spec/editor/views/FileThumbnailView-spec.js b/package/spec/editor/views/FileThumbnailView-spec.js index 323a1c6bce..c59ee6f34d 100644 --- a/package/spec/editor/views/FileThumbnailView-spec.js +++ b/package/spec/editor/views/FileThumbnailView-spec.js @@ -1,11 +1,15 @@ import Marionette from 'backbone.marionette'; -import {FileThumbnailView} from 'pageflow/editor'; +import {AudioFile, FileThumbnailView} from 'pageflow/editor'; import * as support from '$support'; import {renderBackboneView as render} from 'pageflow/testHelpers'; describe('FileThumbnailView', () => { + support.setupGlobals({ + config: {confirmEncodingJobs: false} + }); + const ThumbnailView = Marionette.ItemView.extend({ template: () => '' }); @@ -16,6 +20,10 @@ describe('FileThumbnailView', () => { }); } + function stageIcons(view) { + return view.$el.find('.file_stage_icon'); + } + it('renders background image from thumbnail url', () => { const view = new FileThumbnailView({ model: support.factories.file({thumbnail_url: '/image_thumbnail.jpg'}) @@ -89,4 +97,64 @@ describe('FileThumbnailView', () => { expect(onClose).toHaveBeenCalled(); }); + + it('renders stage icon while file is not ready', () => { + const view = new FileThumbnailView({ + model: support.factories.file({state: 'uploading'}) + }); + + render(view); + + expect(stageIcons(view).length).toEqual(1); + }); + + it('renders icon of stage the file is waiting on', () => { + const view = new FileThumbnailView({ + model: support.factories.file({state: 'processing_failed'}) + }); + + render(view); + + expect(stageIcons(view).find('.file_stage_icon-alert')[0].style.display) + .not.toEqual('none'); + }); + + it('renders no stage icon once file is ready', () => { + const view = new FileThumbnailView({ + model: support.factories.file({state: 'processed'}) + }); + + render(view); + + expect(stageIcons(view).length).toEqual(0); + }); + + it('removes stage icon when file becomes ready', () => { + const file = support.factories.file({state: 'processing'}); + const view = new FileThumbnailView({model: file}); + + render(view); + file.set('state', 'processed'); + + expect(stageIcons(view).length).toEqual(0); + }); + + it('renders no stage icon when no file is set', () => { + const view = new FileThumbnailView({}); + + render(view); + + expect(stageIcons(view).length).toEqual(0); + }); + + it('renders pictogram of file types that define one', () => { + const view = new FileThumbnailView({ + model: new AudioFile({state: 'encoded'}, {fileType: support.factories.fileType()}) + }); + + render(view); + + expect(view.$el.find('.pictogram').hasClass('audio')).toEqual(true); + expect(view.$el.hasClass('always_picogram')).toEqual(true); + }); }); diff --git a/package/src/editor/views/FileThumbnailView.js b/package/src/editor/views/FileThumbnailView.js index 55427a108f..2a78c847f8 100644 --- a/package/src/editor/views/FileThumbnailView.js +++ b/package/src/editor/views/FileThumbnailView.js @@ -1,6 +1,9 @@ import Marionette from 'backbone.marionette'; +import {CollectionView} from 'pageflow/ui'; + import template from '../templates/fileThumbnail.jst'; +import {FileStageIconView} from './FileStageIconView'; export const FileThumbnailView = Marionette.ItemView.extend({ className: 'file_thumbnail', @@ -16,26 +19,23 @@ export const FileThumbnailView = Marionette.ItemView.extend({ }, onRender: function() { + if (this.model) { + this.appendSubview(new CollectionView({ + tagName: 'span', + className: 'file_thumbnail-stage_icon', + collection: this.model.currentStages, + itemViewConstructor: FileStageIconView + })); + } + this.update(); }, update: function() { if (this.model) { - var stage = this.model.currentStage(); - - if (stage) { - this.setStageClassName(stage.get('name')); - this.ui.pictogram.toggleClass('action_required', stage.get('action_required')); - this.ui.pictogram.toggleClass('failed', stage.get('failed')); - } - else { - this.ui.pictogram.removeClass(this.model.stages.pluck('name').join(' ')); - } - this.ui.pictogram.addClass(this.model.thumbnailPictogram); this.$el.css('background-image', this._imageUrl() ? 'url(' + this._imageUrl() + ')' : ''); this.$el - .removeClass('empty') .toggleClass('always_picogram', !!this.model.thumbnailPictogram) .toggleClass('ready', this.model.isReady()); @@ -63,15 +63,7 @@ export const FileThumbnailView = Marionette.ItemView.extend({ } }, - setStageClassName: function(name) { - if (!this.$el.hasClass(name)) { - this.ui.pictogram.removeClass('empty'); - this.ui.pictogram.removeClass(this.model.stages.pluck('name').join(' ')); - this.ui.pictogram.addClass(name); - } - }, - _imageUrl: function() { return this.model.get(this.options.imageUrlPropertyName || 'thumbnail_url'); } -}); \ No newline at end of file +}); From 51b7111f9495113b43117585f1380f1047d27185 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 14 Aug 2026 14:22:51 +0200 Subject: [PATCH 3/3] Deprecate pageflow-uploadable-file-stage mixin Processing stages are no longer represented by icon font pictograms. Instead of silently rendering a stray glyph, calling the mixin now prints a deprecation warning and emits no CSS. --- .../stylesheets/pageflow/mixins/pageflow.scss | 14 +++++++---- doc/creating_file_types.md | 23 +++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/app/assets/stylesheets/pageflow/mixins/pageflow.scss b/app/assets/stylesheets/pageflow/mixins/pageflow.scss index 98baff9c8e..10175faf61 100644 --- a/app/assets/stylesheets/pageflow/mixins/pageflow.scss +++ b/app/assets/stylesheets/pageflow/mixins/pageflow.scss @@ -29,12 +29,16 @@ } } +// Deprecated: The editor displays the same icons for all processing +// stages of a file. Pictograms per stage are no longer supported. @mixin pageflow-uploadable-file-stage($stage-name) { - .editor { - .file_thumbnail .pictogram.#{$stage-name}, - .file_stage_item.#{$stage-name} { - @content; - } + @warn "pageflow-uploadable-file-stage is deprecated and no longer has any effect. " + + "Remove the include for stage '#{$stage-name}'."; + + // Keeps existing call sites from failing since they pass a content + // block. + @if false { + @content; } } diff --git a/doc/creating_file_types.md b/doc/creating_file_types.md index 865b4f57f7..2306221e52 100644 --- a/doc/creating_file_types.md +++ b/doc/creating_file_types.md @@ -308,13 +308,22 @@ know when a file shall be regarded as ready. readyState: 'unpacked' }); -There is a special SCSS mixin which can be used to associate a -pictogram with the processing stage: - - # app/assets/stylesheets/pageflow/panorama/editor.css.scss - @include pageflow-uploadable-file-stage('unpacking') { - @include archive-icon; - } +The editor indicates the stage a file is waiting on with a common set +of icons. All that is left to do, is providing translations for the +states of the stage under +`pageflow.editor.files.stages.`: + + # config/locales/en.yml + en: + pageflow: + editor: + files: + stages: + unpacking: + pending: Unpacking pending. + active: Unpacking in progress. + failed: Unpacking failed. + finished: Unpacking was successful. ## File Thumbnails