product_tours 0.3.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +11 -5
- data/CHANGELOG.md +75 -3
- data/README.md +66 -4
- data/Rakefile +17 -1
- data/SECURITY.md +50 -0
- data/app/controllers/concerns/product_tours/request_context.rb +3 -3
- data/app/controllers/product_tours/dashboard_controller.rb +1 -1
- data/app/controllers/product_tours/interactions_controller.rb +77 -0
- data/app/controllers/product_tours/media_controller.rb +23 -4
- data/app/controllers/product_tours/tours_controller.rb +203 -52
- data/app/helpers/product_tours/{posts_helper.rb → tours_helper.rb} +1 -1
- data/app/models/product_tours/{post.rb → tour.rb} +22 -15
- data/app/views/product_tours/{posts → tours}/_form.html.erb +34 -34
- data/app/views/product_tours/{posts/_post_panel.html.erb → tours/_tour_panel.html.erb} +33 -33
- data/app/views/product_tours/{posts → tours}/edit.html.erb +3 -3
- data/app/views/product_tours/{posts → tours}/index.html.erb +23 -23
- data/app/views/product_tours/tours/new.html.erb +7 -0
- data/app/views/product_tours/{posts → tours}/show.html.erb +2 -2
- data/config/routes.rb +4 -4
- data/docs/ractors.md +138 -0
- data/lib/generators/product_tours/install/install_generator.rb +3 -3
- data/lib/generators/product_tours/install/templates/{create_product_tours_posts.rb.tt → create_product_tours_tours.rb.tt} +5 -5
- data/lib/product_tours/dashboard.css +1 -1
- data/lib/product_tours/dashboard.js +16 -16
- data/lib/product_tours/engine.rb +12 -0
- data/lib/product_tours/ractor_support.rb +519 -0
- data/lib/product_tours/seeds.rb +22 -22
- data/lib/product_tours/version.rb +1 -1
- data/lib/product_tours/widget.js +26 -26
- data/lib/product_tours/widget.rb +3 -3
- data/{app/helpers → lib}/product_tours/widget_helper.rb +4 -0
- data/lib/product_tours.rb +2 -0
- data/lib/tasks/product_tours_tasks.rake +3 -3
- metadata +22 -13
- data/app/controllers/product_tours/posts_controller.rb +0 -228
- data/app/views/product_tours/posts/new.html.erb +0 -7
|
@@ -1,77 +1,228 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ProductTours
|
|
4
|
-
class ToursController <
|
|
5
|
-
|
|
4
|
+
class ToursController < DashboardController
|
|
5
|
+
PER_PAGE = 50
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
before_action :set_tour,
|
|
8
|
+
only: %i[show edit update destroy refresh_video_metadata add_translation publish unpublish]
|
|
9
|
+
before_action :load_linkable_tours, only: %i[new create edit update]
|
|
10
|
+
before_action :load_translations, only: :show
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
def index
|
|
13
|
+
@status = Tour::STATUSES.include?(params[:status]) ? params[:status] : 'published'
|
|
14
|
+
@query = params[:q].to_s.strip.presence
|
|
15
|
+
@counts = Tour.where(locale: default_locale).group(:status).count
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
key_scope = Tour.where(status: @status, locale: default_locale)
|
|
18
|
+
@keys = key_scope.distinct.order(:key).pluck(:key)
|
|
19
|
+
|
|
20
|
+
scope = tours_with_video.newest_first.where(status: @status, locale: default_locale)
|
|
21
|
+
scope = scope.where('LOWER(key) LIKE ?', "%#{Tour.sanitize_sql_like(@query.downcase)}%") if @query
|
|
22
|
+
@page = [params[:page].to_i, 1].max
|
|
23
|
+
@tours = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
|
|
24
|
+
@more = @tours.size > PER_PAGE
|
|
25
|
+
@tours = @tours.first(PER_PAGE)
|
|
26
|
+
|
|
27
|
+
@selected_tour = tours_with_video.find_by(id: params[:tour_id]) if params[:tour_id].present?
|
|
28
|
+
load_translations if @selected_tour
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def show; end
|
|
32
|
+
|
|
33
|
+
def new
|
|
34
|
+
@tour = Tour.new(locale: default_locale)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def create
|
|
38
|
+
@tour = Tour.new(tour_attributes.merge(locale: default_locale))
|
|
39
|
+
if @tour.save
|
|
40
|
+
redirect_to tour_path(@tour), notice: t('product_tours.dashboard.created', default: 'Tutorial created.')
|
|
41
|
+
else
|
|
42
|
+
render :new, status: :unprocessable_entity
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def edit; end
|
|
47
|
+
|
|
48
|
+
def update
|
|
49
|
+
if @tour.update(tour_attributes)
|
|
50
|
+
remove_uploaded_video if remove_uploaded_video?
|
|
51
|
+
redirect_to tour_path(@tour), notice: t('product_tours.dashboard.updated', default: 'Tutorial updated.')
|
|
52
|
+
else
|
|
53
|
+
render :edit, status: :unprocessable_entity
|
|
54
|
+
end
|
|
17
55
|
end
|
|
18
56
|
|
|
19
|
-
def
|
|
20
|
-
|
|
57
|
+
def destroy
|
|
58
|
+
if default_locale_tour?(@tour) && translations_for(@tour).where.not(id: @tour.id).exists?
|
|
59
|
+
redirect_to tour_path(@tour),
|
|
60
|
+
alert: t('product_tours.dashboard.delete_translations_first',
|
|
61
|
+
default: 'Delete this tutorial\'s translations before deleting the ' \
|
|
62
|
+
'default-language tutorial.')
|
|
63
|
+
return
|
|
64
|
+
end
|
|
21
65
|
|
|
22
|
-
|
|
23
|
-
|
|
66
|
+
@tour.destroy!
|
|
67
|
+
redirect_to tours_path,
|
|
68
|
+
notice: t('product_tours.dashboard.deleted', default: 'Tutorial deleted.'), status: :see_other
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def add_translation
|
|
72
|
+
locale = params[:locale].to_s
|
|
73
|
+
unless available_locales.include?(locale) && locale != @tour.locale
|
|
74
|
+
redirect_to tour_path(@tour),
|
|
75
|
+
alert: t('product_tours.dashboard.translation_locale_invalid',
|
|
76
|
+
default: 'Choose another available language.')
|
|
77
|
+
return
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
existing = Tour.find_by(locale: locale, key: @tour.key)
|
|
81
|
+
if existing
|
|
82
|
+
redirect_to tour_path(existing),
|
|
83
|
+
notice: t('product_tours.dashboard.translation_exists',
|
|
84
|
+
default: 'That translation already exists.')
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
source = translations_for(@tour).find_by(locale: default_locale) || @tour
|
|
89
|
+
translation = source.dup
|
|
90
|
+
translation.locale = locale
|
|
91
|
+
translation.status = 'draft'
|
|
92
|
+
if translation.action_tour_key.present? &&
|
|
93
|
+
!Tour.exists?(locale: locale, key: translation.action_tour_key)
|
|
94
|
+
translation.action_tour_key = nil
|
|
95
|
+
end
|
|
96
|
+
translation.save!
|
|
97
|
+
if Tour.description_supported? && source.description.present?
|
|
98
|
+
translation.update!(description: source.description.body)
|
|
99
|
+
end
|
|
100
|
+
translation.video.attach(source.video.blob) if source.uploaded_video?
|
|
101
|
+
|
|
102
|
+
redirect_to edit_tour_path(translation),
|
|
103
|
+
notice: t('product_tours.dashboard.translation_added',
|
|
104
|
+
default: 'Translation added as a draft. Translate its content before publishing.')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def publish
|
|
108
|
+
@tour.update!(status: 'published')
|
|
109
|
+
redirect_to tour_path(@tour), notice: t('product_tours.dashboard.published', default: 'Tutorial published.')
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def unpublish
|
|
113
|
+
@tour.update!(status: 'draft')
|
|
114
|
+
redirect_to tour_path(@tour),
|
|
115
|
+
notice: t('product_tours.dashboard.unpublished', default: 'Tutorial moved to drafts.')
|
|
116
|
+
end
|
|
24
117
|
|
|
25
|
-
|
|
26
|
-
|
|
118
|
+
def video_preview
|
|
119
|
+
resolved = VideoResolver.resolve(params[:url])
|
|
120
|
+
unless resolved
|
|
121
|
+
render json: { error: t('product_tours.dashboard.video_invalid', default: 'Enter a supported video URL.') },
|
|
122
|
+
status: :unprocessable_entity
|
|
123
|
+
return
|
|
124
|
+
end
|
|
27
125
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
126
|
+
metadata = VideoMetadata.fetch(params[:url])
|
|
127
|
+
render json: {
|
|
128
|
+
kind: resolved.kind,
|
|
129
|
+
provider: resolved.provider,
|
|
130
|
+
url: resolved.url,
|
|
131
|
+
title: metadata['provider_title'],
|
|
132
|
+
thumbnail_url: metadata['thumbnail_url']
|
|
133
|
+
}.compact
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def refresh_video_metadata
|
|
137
|
+
metadata = VideoMetadata.fetch(@tour.video_url)
|
|
138
|
+
if metadata.present?
|
|
139
|
+
@tour.update!(video_metadata: @tour.video_metadata.to_h.merge(metadata))
|
|
140
|
+
redirect_to edit_tour_path(@tour),
|
|
141
|
+
notice: t('product_tours.dashboard.metadata_updated', default: 'Video data refreshed.')
|
|
142
|
+
else
|
|
143
|
+
redirect_to edit_tour_path(@tour),
|
|
144
|
+
alert: t('product_tours.dashboard.metadata_failed',
|
|
145
|
+
default: 'Video data could not be fetched. The URL was not changed.')
|
|
146
|
+
end
|
|
39
147
|
end
|
|
40
148
|
|
|
41
149
|
private
|
|
42
150
|
|
|
43
|
-
def
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
151
|
+
def tours_with_video
|
|
152
|
+
Tour.video_upload_supported? ? Tour.with_attached_video : Tour.all
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def set_tour
|
|
156
|
+
@tour = Tour.find(params[:id])
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def tour_params
|
|
160
|
+
permitted = %i[key status title video_url action_label action_url action_tour_key]
|
|
161
|
+
permitted << :description if Tour.description_supported?
|
|
162
|
+
permitted << :video if Tour.video_upload_supported?
|
|
163
|
+
params.require(:tour).permit(*permitted)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def tour_attributes
|
|
167
|
+
attributes = tour_params
|
|
168
|
+
attributes.delete(:key) if identity_locked?
|
|
169
|
+
case params[:video_source]
|
|
170
|
+
when 'url'
|
|
171
|
+
attributes.delete(:video)
|
|
172
|
+
when 'upload'
|
|
173
|
+
attributes[:video_url] = nil
|
|
174
|
+
end
|
|
175
|
+
case params[:action_target]
|
|
176
|
+
when 'close'
|
|
177
|
+
attributes[:action_url] = nil
|
|
178
|
+
attributes[:action_tour_key] = nil
|
|
179
|
+
when 'url'
|
|
180
|
+
attributes[:action_tour_key] = nil
|
|
181
|
+
when 'tour'
|
|
182
|
+
attributes[:action_url] = nil
|
|
183
|
+
end
|
|
184
|
+
attributes
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def load_linkable_tours
|
|
188
|
+
@linkable_tours = Tour.where.not(id: @tour&.id).order(:locale, :title, :key)
|
|
189
|
+
.select(:id, :key, :locale, :status, :title)
|
|
190
|
+
@identity_locked = identity_locked?
|
|
47
191
|
end
|
|
48
192
|
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
key: post.key,
|
|
54
|
-
locale: post.locale,
|
|
55
|
-
title: post.title,
|
|
56
|
-
descriptionHtml: description_html(post),
|
|
57
|
-
video: video,
|
|
58
|
-
action: {
|
|
59
|
-
label: post.action_label.presence || default_action_label(post),
|
|
60
|
-
url: post.action_url.presence,
|
|
61
|
-
postKey: post.action_post_key.presence
|
|
62
|
-
}
|
|
63
|
-
}
|
|
193
|
+
def load_translations
|
|
194
|
+
tour = @selected_tour || @tour
|
|
195
|
+
@translations = translations_for(tour).order(:locale).to_a
|
|
196
|
+
@missing_translation_locales = available_locales - @translations.map(&:locale)
|
|
64
197
|
end
|
|
65
198
|
|
|
66
|
-
def
|
|
67
|
-
key
|
|
68
|
-
I18n.t(key, scope: :product_tours, default: key.to_s.humanize)
|
|
199
|
+
def translations_for(tour)
|
|
200
|
+
Tour.where(key: tour.key)
|
|
69
201
|
end
|
|
70
202
|
|
|
71
|
-
def
|
|
72
|
-
|
|
203
|
+
def available_locales
|
|
204
|
+
(I18n.available_locales.map(&:to_s) + [default_locale]).uniq.sort
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def default_locale
|
|
208
|
+
I18n.default_locale.to_s
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def default_locale_tour?(tour)
|
|
212
|
+
tour.locale == default_locale
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def identity_locked?
|
|
216
|
+
@tour&.persisted? &&
|
|
217
|
+
(!default_locale_tour?(@tour) || translations_for(@tour).where.not(id: @tour.id).exists?)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def remove_uploaded_video?
|
|
221
|
+
params[:video_source] == 'url' && @tour.uploaded_video?
|
|
222
|
+
end
|
|
73
223
|
|
|
74
|
-
|
|
224
|
+
def remove_uploaded_video
|
|
225
|
+
@tour.video.purge if @tour.uploaded_video?
|
|
75
226
|
end
|
|
76
227
|
end
|
|
77
228
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ProductTours
|
|
4
|
-
module
|
|
4
|
+
module ToursHelper
|
|
5
5
|
LOCALE_NAMES = {
|
|
6
6
|
'ar' => 'Arabic', 'bg' => 'Bulgarian', 'bn' => 'Bengali', 'de' => 'German', 'el' => 'Greek',
|
|
7
7
|
'en' => 'English', 'es' => 'Spanish', 'fr' => 'French', 'hi' => 'Hindi', 'hr' => 'Croatian',
|
|
@@ -3,13 +3,20 @@
|
|
|
3
3
|
require 'uri'
|
|
4
4
|
|
|
5
5
|
module ProductTours
|
|
6
|
-
class
|
|
6
|
+
class Tour < ApplicationRecord
|
|
7
7
|
KEY_FORMAT = /\A[a-z0-9]+(?:[._-][a-z0-9]+)*\z/
|
|
8
8
|
STATUSES = %w[draft published].freeze
|
|
9
9
|
DASHBOARD_STATUSES = %w[published draft].freeze
|
|
10
10
|
|
|
11
11
|
enum :status, STATUSES.index_by(&:itself), default: :draft
|
|
12
12
|
|
|
13
|
+
# Explicit methods work in non-main Ractors; Rails' enum-generated
|
|
14
|
+
# predicates and bang methods are currently unisolated closures.
|
|
15
|
+
def draft? = status_for_database == 'draft'
|
|
16
|
+
def draft! = update!(status: 'draft')
|
|
17
|
+
def published? = status_for_database == 'published'
|
|
18
|
+
def published! = update!(status: 'published')
|
|
19
|
+
|
|
13
20
|
has_rich_text :description if respond_to?(:has_rich_text)
|
|
14
21
|
|
|
15
22
|
has_one_attached :video, service: ProductTours.config.storage_service if defined?(::ActiveStorage)
|
|
@@ -19,7 +26,7 @@ module ProductTours
|
|
|
19
26
|
validates :status, inclusion: { in: STATUSES }
|
|
20
27
|
validates :title, presence: true
|
|
21
28
|
validate :action_has_one_destination
|
|
22
|
-
validate :
|
|
29
|
+
validate :action_tour_is_linkable
|
|
23
30
|
validate :action_url_is_safe
|
|
24
31
|
validate :video_url_is_supported
|
|
25
32
|
validate :uploaded_file_is_video
|
|
@@ -61,10 +68,10 @@ module ProductTours
|
|
|
61
68
|
}.compact
|
|
62
69
|
end
|
|
63
70
|
|
|
64
|
-
def
|
|
65
|
-
return if
|
|
71
|
+
def action_tour
|
|
72
|
+
return if action_tour_key.blank?
|
|
66
73
|
|
|
67
|
-
self.class.find_by(locale: locale, key:
|
|
74
|
+
self.class.find_by(locale: locale, key: action_tour_key)
|
|
68
75
|
end
|
|
69
76
|
|
|
70
77
|
private
|
|
@@ -97,25 +104,25 @@ module ProductTours
|
|
|
97
104
|
end
|
|
98
105
|
|
|
99
106
|
def action_has_one_destination
|
|
100
|
-
return unless action_url.present? &&
|
|
107
|
+
return unless action_url.present? && action_tour_key.present?
|
|
101
108
|
|
|
102
|
-
errors.add(:base, 'Primary action can open a URL or another
|
|
109
|
+
errors.add(:base, 'Primary action can open a URL or another tour, not both')
|
|
103
110
|
end
|
|
104
111
|
|
|
105
|
-
def
|
|
106
|
-
return if
|
|
112
|
+
def action_tour_is_linkable
|
|
113
|
+
return if action_tour_key.blank?
|
|
107
114
|
|
|
108
|
-
unless
|
|
109
|
-
errors.add(:
|
|
115
|
+
unless action_tour_key.match?(KEY_FORMAT)
|
|
116
|
+
errors.add(:action_tour_key, 'must use a valid tour key')
|
|
110
117
|
return
|
|
111
118
|
end
|
|
112
|
-
if
|
|
113
|
-
errors.add(:
|
|
119
|
+
if action_tour_key == key
|
|
120
|
+
errors.add(:action_tour_key, 'cannot link to the same tour')
|
|
114
121
|
return
|
|
115
122
|
end
|
|
116
|
-
return if locale.blank? || self.class.exists?(locale: locale, key:
|
|
123
|
+
return if locale.blank? || self.class.exists?(locale: locale, key: action_tour_key)
|
|
117
124
|
|
|
118
|
-
errors.add(:
|
|
125
|
+
errors.add(:action_tour_key, 'must identify an existing tour in the same locale')
|
|
119
126
|
end
|
|
120
127
|
|
|
121
128
|
def uploaded_file_is_video
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
<%= form_with model:
|
|
2
|
-
<% action_target =
|
|
3
|
-
<%
|
|
1
|
+
<%= form_with model: tour, class: "tour-form" do |form| %>
|
|
2
|
+
<% action_target = tour.action_tour_key.present? ? "tour" : (tour.action_url.present? ? "url" : "close") %>
|
|
3
|
+
<% linkable_tours = @linkable_tours.map do |candidate|
|
|
4
4
|
{ id: candidate.id, key: candidate.key, locale: candidate.locale, status: candidate.status,
|
|
5
5
|
label: "#{candidate.title} — #{candidate.key} (#{candidate.status})" }
|
|
6
6
|
end %>
|
|
7
|
-
<% video_source = params[:video_source].presence || (
|
|
8
|
-
<% if
|
|
7
|
+
<% video_source = params[:video_source].presence || (tour.uploaded_video? ? "upload" : "url") %>
|
|
8
|
+
<% if tour.errors.any? %>
|
|
9
9
|
<div class="errors" role="alert">
|
|
10
10
|
<strong><%= t("product_tours.dashboard.fix_errors", default: "Fix the following fields:") %></strong>
|
|
11
|
-
<ul><%
|
|
11
|
+
<ul><% tour.errors.full_messages.each do |message| %><li><%= message %></li><% end %></ul>
|
|
12
12
|
</div>
|
|
13
13
|
<% end %>
|
|
14
14
|
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
<%= form.label :locale, t("product_tours.dashboard.language", default: "Language") %>
|
|
37
37
|
<%= form.text_field :locale, disabled: true %>
|
|
38
38
|
<small>
|
|
39
|
-
<% if
|
|
39
|
+
<% if tour.new_record? %>
|
|
40
40
|
<%= t("product_tours.dashboard.default_language_hint", default: "New tutorials start in your app's default language. Add translations from the tutorial page.") %>
|
|
41
41
|
<% else %>
|
|
42
42
|
<%= t("product_tours.dashboard.language_locked_hint", default: "Add and open other languages from the tutorial page.") %>
|
|
@@ -45,11 +45,11 @@
|
|
|
45
45
|
</div>
|
|
46
46
|
<div class="field">
|
|
47
47
|
<%= form.label :status, t("product_tours.dashboard.publication_status", default: "Publication status") %>
|
|
48
|
-
<%= form.select :status, ProductTours::
|
|
48
|
+
<%= form.select :status, ProductTours::Tour::STATUSES.map { |status| [status.humanize, status] } %>
|
|
49
49
|
</div>
|
|
50
50
|
</section>
|
|
51
51
|
|
|
52
|
-
<% if ProductTours::
|
|
52
|
+
<% if ProductTours::Tour.description_supported? %>
|
|
53
53
|
<section class="form-section">
|
|
54
54
|
<h2><%= t("product_tours.dashboard.description", default: "Description") %></h2>
|
|
55
55
|
<div class="rich-editor" data-rich-editor>
|
|
@@ -60,18 +60,18 @@
|
|
|
60
60
|
<button type="button" data-rich-command="insertOrderedList" aria-label="<%= t("product_tours.dashboard.numbered_list", default: "Numbered list") %>">1.</button>
|
|
61
61
|
<button type="button" data-rich-command="createLink" aria-label="<%= t("product_tours.dashboard.link", default: "Link") %>">↗</button>
|
|
62
62
|
</div>
|
|
63
|
-
<%= form.hidden_field :description, value:
|
|
63
|
+
<%= form.hidden_field :description, value: tour.description.body.to_s, data: { rich_input: true } %>
|
|
64
64
|
<div class="rich-content" contenteditable="true" role="textbox" aria-multiline="true"
|
|
65
65
|
aria-label="<%= t("product_tours.dashboard.description", default: "Description") %>"
|
|
66
|
-
data-rich-content><%=
|
|
66
|
+
data-rich-content><%= tour.description.body.to_s.html_safe %></div>
|
|
67
67
|
</div>
|
|
68
68
|
</section>
|
|
69
69
|
<% end %>
|
|
70
70
|
|
|
71
71
|
<section class="form-section">
|
|
72
72
|
<h2><%= t("product_tours.dashboard.video", default: "Video") %></h2>
|
|
73
|
-
<% if ProductTours::
|
|
74
|
-
<fieldset class="video-source-picker" data-video-source-picker data-has-upload="<%=
|
|
73
|
+
<% if ProductTours::Tour.video_upload_supported? %>
|
|
74
|
+
<fieldset class="video-source-picker" data-video-source-picker data-has-upload="<%= tour.uploaded_video? %>">
|
|
75
75
|
<legend class="sr-only"><%= t("product_tours.dashboard.video_source", default: "Video source") %></legend>
|
|
76
76
|
<label class="action-choice">
|
|
77
77
|
<%= radio_button_tag :video_source, "url", video_source == "url", data: { video_source: true } %>
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
<small><%= t("product_tours.dashboard.video_hint", default: "YouTube, Vimeo, Loom, Tella, Voomly, or a direct MP4/WebM URL.") %></small>
|
|
95
95
|
</div>
|
|
96
96
|
<div class="form-video-preview" data-video-preview
|
|
97
|
-
data-endpoint="<%=
|
|
97
|
+
data-endpoint="<%= video_preview_tours_path %>"
|
|
98
98
|
data-loading="<%= t("product_tours.dashboard.video_loading", default: "Loading preview…") %>"
|
|
99
99
|
data-error="<%= t("product_tours.dashboard.video_preview_failed", default: "This video could not be previewed.") %>"
|
|
100
100
|
hidden>
|
|
@@ -103,14 +103,14 @@
|
|
|
103
103
|
</div>
|
|
104
104
|
</div>
|
|
105
105
|
|
|
106
|
-
<% if ProductTours::
|
|
106
|
+
<% if ProductTours::Tour.video_upload_supported? %>
|
|
107
107
|
<div data-video-source-fields="upload" <%= "hidden" unless video_source == "upload" %>>
|
|
108
108
|
<div class="field">
|
|
109
109
|
<%= form.label :video, t("product_tours.dashboard.video_file", default: "Video file") %>
|
|
110
110
|
<%= form.file_field :video, accept: "video/*", disabled: video_source != "upload",
|
|
111
|
-
required: video_source == "upload" && !
|
|
112
|
-
<% if
|
|
113
|
-
<small><%= t("product_tours.dashboard.current_video", default: "Current file: %{filename}", filename:
|
|
111
|
+
required: video_source == "upload" && !tour.uploaded_video? %>
|
|
112
|
+
<% if tour.uploaded_video? %>
|
|
113
|
+
<small><%= t("product_tours.dashboard.current_video", default: "Current file: %{filename}", filename: tour.video.filename) %></small>
|
|
114
114
|
<% end %>
|
|
115
115
|
</div>
|
|
116
116
|
</div>
|
|
@@ -128,9 +128,9 @@
|
|
|
128
128
|
<small><%= t("product_tours.dashboard.action_finish_hint", default: "Marks this tutorial complete and closes the modal.") %></small></span>
|
|
129
129
|
</label>
|
|
130
130
|
<label class="action-choice">
|
|
131
|
-
<%= radio_button_tag :action_target, "
|
|
132
|
-
<span><strong><%= t("product_tours.dashboard.
|
|
133
|
-
<small><%= t("product_tours.dashboard.
|
|
131
|
+
<%= radio_button_tag :action_target, "tour", action_target == "tour", data: { action_target: true } %>
|
|
132
|
+
<span><strong><%= t("product_tours.dashboard.action_tour", default: "Continue to another tutorial") %></strong>
|
|
133
|
+
<small><%= t("product_tours.dashboard.action_tour_hint", default: "Opens it in the same modal and automatically adds a Back button.") %></small></span>
|
|
134
134
|
</label>
|
|
135
135
|
<label class="action-choice">
|
|
136
136
|
<%= radio_button_tag :action_target, "url", action_target == "url", data: { action_target: true } %>
|
|
@@ -141,21 +141,21 @@
|
|
|
141
141
|
|
|
142
142
|
<div class="field">
|
|
143
143
|
<%= form.label :action_label, t("product_tours.dashboard.action_label", default: "Button label") %>
|
|
144
|
-
<%= form.text_field :action_label, placeholder: action_target == "
|
|
144
|
+
<%= form.text_field :action_label, placeholder: action_target == "tour" ? t("product_tours.next", default: "Next") : t("product_tours.done", default: "Done"),
|
|
145
145
|
data: { action_label: true } %>
|
|
146
146
|
<small><%= t("product_tours.dashboard.action_label_hint", default: "Optional. A sensible label is used when left blank.") %></small>
|
|
147
147
|
</div>
|
|
148
148
|
|
|
149
|
-
<div data-action-fields="
|
|
149
|
+
<div data-action-fields="tour">
|
|
150
150
|
<div class="field">
|
|
151
|
-
<%= form.label :
|
|
152
|
-
<%
|
|
153
|
-
<%
|
|
154
|
-
<%= form.select :
|
|
155
|
-
{ include_blank: t("product_tours.dashboard.
|
|
156
|
-
required: true, data: {
|
|
157
|
-
|
|
158
|
-
<small><%= t("product_tours.dashboard.
|
|
151
|
+
<%= form.label :action_tour_key, t("product_tours.dashboard.next_tour", default: "Next tutorial") %>
|
|
152
|
+
<% same_locale_tours = @linkable_tours.select { |candidate| candidate.locale == tour.locale } %>
|
|
153
|
+
<% tour_options = same_locale_tours.map { |candidate| ["#{candidate.title} — #{candidate.key} (#{candidate.status})", candidate.key] } %>
|
|
154
|
+
<%= form.select :action_tour_key, options_for_select(tour_options, tour.action_tour_key),
|
|
155
|
+
{ include_blank: t("product_tours.dashboard.select_next_tour", default: "Select the next tutorial…") },
|
|
156
|
+
required: true, data: { action_tour_select: true, selected_key: tour.action_tour_key,
|
|
157
|
+
current_tour_id: tour.id } %>
|
|
158
|
+
<small><%= t("product_tours.dashboard.next_tour_hint", default: "Tutorials must use the same language. Draft tutorials can be linked while you prepare the sequence.") %></small>
|
|
159
159
|
</div>
|
|
160
160
|
</div>
|
|
161
161
|
|
|
@@ -166,12 +166,12 @@
|
|
|
166
166
|
<small><%= t("product_tours.dashboard.action_hint", default: "Use an app path such as /settings or a complete HTTP(S) URL.") %></small>
|
|
167
167
|
</div>
|
|
168
168
|
</div>
|
|
169
|
-
<script type="application/json" data-action-
|
|
169
|
+
<script type="application/json" data-action-tour-options><%= raw json_escape(linkable_tours.to_json) %></script>
|
|
170
170
|
</section>
|
|
171
171
|
|
|
172
172
|
<div class="form-actions">
|
|
173
|
-
<%= form.submit(
|
|
174
|
-
<%= link_to t("product_tours.dashboard.cancel", default: "Cancel"),
|
|
173
|
+
<%= form.submit(tour.persisted? ? t("product_tours.dashboard.save", default: "Save changes") : t("product_tours.dashboard.create", default: "Create tutorial"), class: "button primary") %>
|
|
174
|
+
<%= link_to t("product_tours.dashboard.cancel", default: "Cancel"), tour.persisted? ? tour_path(tour) : tours_path, class: "button" %>
|
|
175
175
|
</div>
|
|
176
176
|
</div>
|
|
177
177
|
<% end %>
|