diff --git a/app/controllers/api/template_folders_autocomplete_controller.rb b/app/controllers/api/template_folders_autocomplete_controller.rb new file mode 100644 index 00000000..19365e76 --- /dev/null +++ b/app/controllers/api/template_folders_autocomplete_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Api + class TemplateFoldersAutocompleteController < ApiBaseController + load_and_authorize_resource :template_folder, parent: false + + LIMIT = 100 + + def index + template_folders = @template_folders.joins(:templates).where(templates: { deleted_at: nil }).distinct + template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT) + + render json: template_folders.as_json(only: %i[name deleted_at]) + end + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index fe7c1c5b..3f9eff1d 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -6,17 +6,56 @@ class DashboardController < ApplicationController before_action :maybe_redirect_product_url before_action :maybe_render_landing + load_and_authorize_resource :template_folder, parent: false load_and_authorize_resource :template, parent: false + SHOW_TEMPLATES_FOLDERS_THRESHOLD = 9 + TEMPLATES_PER_PAGE = 12 + FOLDERS_PER_PAGE = 18 + def index - @templates = @templates.active.preload(:author).order(id: :desc) - @templates = Templates.search(@templates, params[:q]) + @template_folders = filter_template_folders(@template_folders) + + @pagy, @template_folders = pagy( + @template_folders, + items: FOLDERS_PER_PAGE, + page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1 + ) + + if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD + @templates = @templates.none + else + @template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME } + @templates = filter_templates(@templates) - @pagy, @templates = pagy(@templates, items: 12) + items = + if @template_folders.size < 4 + TEMPLATES_PER_PAGE + else + (@template_folders.size < 7 ? 9 : 6) + end + + @pagy, @templates = pagy(@templates, items:) + end end private + def filter_template_folders(template_folders) + rel = template_folders.joins(:active_templates) + .order(id: :desc) + .distinct + + TemplateFolders.search(rel, params[:q]) + end + + def filter_templates(templates) + rel = templates.active.preload(:author).order(id: :desc) + .where(folder_id: current_account.default_template_folder.id) + + Templates.search(rel, params[:q]) + end + def maybe_redirect_product_url return if !Docuseal.multitenant? || signed_in? diff --git a/app/controllers/template_folders_controller.rb b/app/controllers/template_folders_controller.rb new file mode 100644 index 00000000..80b81632 --- /dev/null +++ b/app/controllers/template_folders_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class TemplateFoldersController < ApplicationController + load_and_authorize_resource :template_folder + + def show + @templates = @template_folder.templates.active.preload(:author).order(id: :desc) + @templates = Templates.search(@templates, params[:q]) + + @pagy, @templates = pagy(@templates, items: 12) + end + + def edit; end + + def update + if @template_folder != current_account.default_template_folder && + @template_folder.update(template_folder_params) + redirect_to folder_path(@template_folder), notice: 'Folder name has been updated' + else + redirect_to folder_path(@template_folder), alert: 'Unable to rename folder' + end + end + + private + + def template_folder_params + params.require(:template_folder).permit(:name) + end +end diff --git a/app/controllers/templates_archived_controller.rb b/app/controllers/templates_archived_controller.rb index f598b817..82f286d0 100644 --- a/app/controllers/templates_archived_controller.rb +++ b/app/controllers/templates_archived_controller.rb @@ -4,7 +4,7 @@ class TemplatesArchivedController < ApplicationController load_and_authorize_resource :template, parent: false def index - @templates = @templates.where.not(deleted_at: nil).preload(:author).order(id: :desc) + @templates = @templates.where.not(deleted_at: nil).preload(:author, :folder).order(id: :desc) @templates = Templates.search(@templates, params[:q]) @pagy, @templates = pagy(@templates, items: 12) diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index 5d9a7e27..69d0781a 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -31,6 +31,7 @@ class TemplatesController < ApplicationController def create @template.account = current_account @template.author = current_user + @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) @template.assign_attributes(@base_template.slice(:fields, :schema, :submitters)) if @base_template if @template.save diff --git a/app/controllers/templates_folders_controller.rb b/app/controllers/templates_folders_controller.rb new file mode 100644 index 00000000..70b6e341 --- /dev/null +++ b/app/controllers/templates_folders_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class TemplatesFoldersController < ApplicationController + load_and_authorize_resource :template + + def edit; end + + def update + @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:name]) + + if @template.save + redirect_back(fallback_location: template_path(@template), notice: 'Document template has been moved') + else + redirect_back(fallback_location: template_path(@template), notice: 'Unable to move template into folder') + end + end + + private + + def template_folder_params + params.require(:template_folder).permit(:name) + end +end diff --git a/app/controllers/templates_uploads_controller.rb b/app/controllers/templates_uploads_controller.rb index 1a111dee..07d0e4d2 100644 --- a/app/controllers/templates_uploads_controller.rb +++ b/app/controllers/templates_uploads_controller.rb @@ -6,6 +6,7 @@ class TemplatesUploadsController < ApplicationController def create @template.account = current_account @template.author = current_user + @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) @template.name = File.basename(params[:files].first.original_filename, '.*') @template.save! diff --git a/app/javascript/application.js b/app/javascript/application.js index f9d1e043..0bc13551 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -16,6 +16,7 @@ import SetOriginUrl from './elements/set_origin_url' import SetTimezone from './elements/set_timezone' import AutoresizeTextarea from './elements/autoresize_textarea' import SubmittersAutocomplete from './elements/submitter_autocomplete' +import FolderAutocomplete from './elements/folder_autocomplete' import * as TurboInstantClick from './lib/turbo_instant_click' @@ -45,6 +46,7 @@ window.customElements.define('set-origin-url', SetOriginUrl) window.customElements.define('set-timezone', SetTimezone) window.customElements.define('autoresize-textarea', AutoresizeTextarea) window.customElements.define('submitters-autocomplete', SubmittersAutocomplete) +window.customElements.define('folder-autocomplete', FolderAutocomplete) document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) document.addEventListener('turbo:submit-end', async (event) => { diff --git a/app/javascript/elements/folder_autocomplete.js b/app/javascript/elements/folder_autocomplete.js new file mode 100644 index 00000000..3d0bb4e3 --- /dev/null +++ b/app/javascript/elements/folder_autocomplete.js @@ -0,0 +1,43 @@ +import autocomplete from 'autocompleter' + +export default class extends HTMLElement { + connectedCallback () { + autocomplete({ + input: this.input, + preventSubmit: this.dataset.submitOnSelect === 'true' ? 0 : 1, + minLength: 0, + showOnFocus: true, + onSelect: this.onSelect, + render: this.render, + fetch: this.fetch + }) + } + + onSelect = (item) => { + this.input.value = item.name + } + + fetch = (text, resolve) => { + const queryParams = new URLSearchParams({ q: text }) + + fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => { + const items = await resp.json() + + resolve(items) + }).catch(() => { + resolve([]) + }) + } + + render = (item) => { + const div = document.createElement('div') + + div.textContent = item.name + + return div + } + + get input () { + return this.querySelector('input') + } +} diff --git a/app/models/account.rb b/app/models/account.rb index bec71f3e..a9659b21 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -17,7 +17,7 @@ class Account < ApplicationRecord has_many :account_configs, dependent: :destroy has_many :templates, dependent: :destroy has_many :template_folders, dependent: :destroy - has_one :default_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) }, + has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) }, class_name: 'TemplateFolder', dependent: :destroy, inverse_of: :account has_many :submissions, through: :templates has_many :submitters, through: :submissions @@ -27,9 +27,8 @@ class Account < ApplicationRecord attribute :timezone, :string, default: 'UTC' attribute :locale, :string, default: 'en-US' - def default_folder - super || template_folders.new(name: TemplateFolder::DEFAULT_NAME, - author_id: users.minimum(:id)) - .tap(&:save!) + def default_template_folder + super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME, + author_id: users.minimum(:id)).tap(&:save!) end end diff --git a/app/models/template.rb b/app/models/template.rb index 3ef60fa4..2168689d 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -62,6 +62,6 @@ class Template < ApplicationRecord private def maybe_set_default_folder - self.folder ||= account.default_folder + self.folder ||= account.default_template_folder end end diff --git a/app/models/template_folder.rb b/app/models/template_folder.rb index e7fa4e84..c01fe30c 100644 --- a/app/models/template_folder.rb +++ b/app/models/template_folder.rb @@ -28,7 +28,13 @@ class TemplateFolder < ApplicationRecord belongs_to :author, class_name: 'User' belongs_to :account - has_many :templates, dependent: :destroy + has_many :templates, dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder + has_many :active_templates, -> { where(deleted_at: nil) }, + class_name: 'Template', dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder scope :active, -> { where(deleted_at: nil) } + + def default? + name == DEFAULT_NAME + end end diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 4887abba..2bccb360 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -3,7 +3,7 @@

Templates

- <% if params[:q].present? || @pagy.pages > 1 %> + <% if params[:q].present? || @pagy.pages > 1 || @template_folders.present? %> <%= render 'shared/search_input' %> <% end %> <% if can?(:create, ::Template) %> @@ -15,17 +15,31 @@ <% end %>
- <% if @pagy.count > 0 %> -
- <%= render partial: 'templates/template', collection: @templates %> + <% view_archived_html = capture do %> + <% if current_account.templates.where.not(deleted_at: nil).exists? %> +
+ View Archived +
+ <% end %> + <% end %> + <% if @template_folders.present? %> +
+ <%= render partial: 'template_folders/folder', collection: @template_folders, as: :folder %>
- <% view_archived_html = capture do %> - <% if current_account.templates.where.not(deleted_at: nil).exists? %> -
- View Archived + <% if @templates.blank? %> + <% if @pagy.pages > 1 %> + <%= render 'shared/pagination', pagy: @pagy, left_additional_html: view_archived_html %> + <% elsif params[:q].blank? %> +
+ <%= view_archived_html %>
<% end %> <% end %> + <% end %> + <% if @templates.present? %> +
+ <%= render partial: 'templates/template', collection: @templates %> +
<% if @pagy.pages > 1 %> <%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html %> <% else %> diff --git a/app/views/icons/_arrow_up_right.html.erb b/app/views/icons/_arrow_up_right.html.erb new file mode 100644 index 00000000..183c004d --- /dev/null +++ b/app/views/icons/_arrow_up_right.html.erb @@ -0,0 +1,5 @@ + + + + + diff --git a/app/views/icons/_folder.html.erb b/app/views/icons/_folder.html.erb new file mode 100644 index 00000000..240be50e --- /dev/null +++ b/app/views/icons/_folder.html.erb @@ -0,0 +1,4 @@ + + + + diff --git a/app/views/icons/_folder_share.html.erb b/app/views/icons/_folder_share.html.erb new file mode 100644 index 00000000..425aa0fd --- /dev/null +++ b/app/views/icons/_folder_share.html.erb @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/views/icons/_pencil_share.html.erb b/app/views/icons/_pencil_share.html.erb new file mode 100644 index 00000000..522074a6 --- /dev/null +++ b/app/views/icons/_pencil_share.html.erb @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/views/shared/_pagination.html.erb b/app/views/shared/_pagination.html.erb index 1a2771ae..e53cee87 100644 --- a/app/views/shared/_pagination.html.erb +++ b/app/views/shared/_pagination.html.erb @@ -2,7 +2,7 @@ <% if @pagy.pages > 1 %>
diff --git a/app/views/template_folders/_folder.html.erb b/app/views/template_folders/_folder.html.erb new file mode 100644 index 00000000..8544e3ed --- /dev/null +++ b/app/views/template_folders/_folder.html.erb @@ -0,0 +1,12 @@ +<% is_long = folder.name.size > 32 %> + + <% if !is_long %> + <%= svg_icon('folder', class: 'w-6 h-6') %> + <% end %> +
+ <% if is_long %> + <%= svg_icon('folder', class: 'w-6 h-6 inline') %> + <% end %> + <%= folder.name %> +
+
diff --git a/app/views/template_folders/edit.html.erb b/app/views/template_folders/edit.html.erb new file mode 100644 index 00000000..102d16c2 --- /dev/null +++ b/app/views/template_folders/edit.html.erb @@ -0,0 +1,10 @@ +<%= render 'shared/turbo_modal', title: 'Rename Folder' do %> + <%= form_for @template_folder, url: folder_path(@template_folder), data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %> +
+ <%= f.text_field :name, required: true, placeholder: 'Folder Name...', class: 'base-input w-full', autofocus: true %> +
+
+ <%= f.button button_title(title: 'Rename', disabled_with: 'Saving'), class: 'base-button' %> +
+ <% end %> +<% end %> diff --git a/app/views/template_folders/show.html.erb b/app/views/template_folders/show.html.erb new file mode 100644 index 00000000..8dc3f9d0 --- /dev/null +++ b/app/views/template_folders/show.html.erb @@ -0,0 +1,45 @@ +
+ <%= link_to root_path do %> + ← + Home + <% end %> +
+
+

+ <%= svg_icon('folder', class: 'w-9 h-9 flex-shrink-0') %> + + <%= @template_folder.name %> + + <% if can?(:update, @template_folder) && @template_folder.name != TemplateFolder::DEFAULT_NAME %> + + + <%= svg_icon('pencil', class: 'w-7 h-7') %> + + + <% end %> +

+
+ <% if params[:q].present? || @pagy.pages > 1 %> + <%= render 'shared/search_input' %> + <% end %> + <% if can?(:create, ::Template) %> + <%= render 'templates/upload_button', folder_name: @template_folder.name %> + <%= link_to new_template_path(folder_name: @template_folder.name), class: 'btn btn-primary text-base btn-md gap-2', data: { turbo_frame: :modal } do %> + <%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %> + + <% end %> + <% end %> +
+
+<% if @pagy.count > 0 %> +
+ <%= render partial: 'templates/template', collection: @templates %> +
+ <%= render 'shared/pagination', pagy: @pagy, items_name: 'templates' %> +<% elsif params[:q].present? %> +
+
+ Templates not Found +
+
+<% end %> diff --git a/app/views/templates/_template.html.erb b/app/views/templates/_template.html.erb index fb40d738..a75e0b13 100644 --- a/app/views/templates/_template.html.erb +++ b/app/views/templates/_template.html.erb @@ -8,33 +8,58 @@ <%= svg_icon('user', class: 'w-4 h-4') %> <%= template.author.full_name.presence || template.author.email %>

-

- <%= svg_icon('calendar', class: 'w-4 h-4') %> - <%= l(template.created_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %> +

+ + <%= svg_icon('calendar', class: 'w-4 h-4') %> + <%= l(template.created_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %> + + <% if template.deleted_at? %> + + <%= svg_icon('folder', class: 'w-4 h-4 flex-shrink-0') %> + <%= template.folder.name %> + + <% end %>

- diff --git a/app/views/templates/_title.html.erb b/app/views/templates/_title.html.erb index d6d6cc92..92993b3c 100644 --- a/app/views/templates/_title.html.erb +++ b/app/views/templates/_title.html.erb @@ -1,11 +1,28 @@ -
-

- <%= template.name %> - <% if template.deleted_at? %> - Archived - <% end %> -

-
+
+
+

+ <%= template.name %> + <% if template.deleted_at? %> + Archived + <% end %> +

+ +
+
<% if !template.deleted_at? && can?(:destroy, template) %> <%= button_to button_title(title: 'Remove', disabled_with: 'Removing', icon: svg_icon('trash', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm', method: :delete, data: { turbo_confirm: 'Are you sure?' } %> <% end %> diff --git a/app/views/templates/_upload_button.html.erb b/app/views/templates/_upload_button.html.erb index 15f80f94..3a92db33 100644 --- a/app/views/templates/_upload_button.html.erb +++ b/app/views/templates/_upload_button.html.erb @@ -14,4 +14,5 @@ + <% end %> diff --git a/app/views/templates/new.html.erb b/app/views/templates/new.html.erb index bccbada2..db5c47b7 100644 --- a/app/views/templates/new.html.erb +++ b/app/views/templates/new.html.erb @@ -1,12 +1,23 @@ <%= render 'shared/turbo_modal', title: @base_template ? 'Clone Template' : 'New Document Template' do %> - <%= form_for @template, data: { turbo_frame: :_top } do |f| %> + <%= form_for @template, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %> <% if @base_template %> <%= hidden_field_tag :base_template_id, @base_template.id %> <% end %> -
+
<%= f.text_field :name, required: true, placeholder: 'Template Name', class: 'base-input' %>
-
+ +
<%= f.button button_title(title: @base_template ? 'Submit' : 'Create', disabled_with: 'Creating'), class: 'base-button' %>
<% end %> diff --git a/app/views/templates_archived/index.html.erb b/app/views/templates_archived/index.html.erb index 8e194c2d..bcdee860 100644 --- a/app/views/templates_archived/index.html.erb +++ b/app/views/templates_archived/index.html.erb @@ -1,5 +1,5 @@
- <%= link_to root_path(@template) do %> + <%= link_to root_path do %> ← Back to Active <% end %> diff --git a/app/views/templates_folders/edit.html.erb b/app/views/templates_folders/edit.html.erb new file mode 100644 index 00000000..2f8e1ecd --- /dev/null +++ b/app/views/templates_folders/edit.html.erb @@ -0,0 +1,12 @@ +<%= render 'shared/turbo_modal', title: 'Move Into Folder' do %> + <%= form_for '', url: template_folder_path(@template), method: :put, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %> +
+ + <%= f.text_field :name, required: true, placeholder: 'New Folder Name...', class: 'base-input w-full', autofocus: true %> + +
+
+ <%= f.button button_title(title: 'Move', disabled_with: 'Moving'), class: 'base-button' %> +
+ <% end %> +<% end %> diff --git a/config/routes.rb b/config/routes.rb index 921366b8..478076c8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,6 +32,7 @@ Rails.application.routes.draw do namespace :api, defaults: { format: :json } do resources :attachments, only: %i[create] resources :submitters_autocomplete, only: %i[index] + resources :template_folders_autocomplete, only: %i[index] resources :submitter_email_clicks, only: %i[create] resources :submitter_form_views, only: %i[create] resources :submissions, only: %i[create] @@ -51,10 +52,12 @@ Rails.application.routes.draw do resources :console_redirect, only: %i[index] resource :templates_upload, only: %i[create] resources :templates_archived, only: %i[index], path: 'archived' + resources :folders, only: %i[show edit update destroy], controller: 'template_folders' resources :templates, only: %i[new create edit show destroy] do resources :restore, only: %i[create], controller: 'templates_restore' resources :archived, only: %i[index], controller: 'templates_archived_submissions' resources :submissions, only: %i[new create] + resource :folder, only: %i[edit update], controller: 'templates_folders' resources :submissions_export, only: %i[index new] end diff --git a/lib/accounts.rb b/lib/accounts.rb index 935f31a1..8fe0df0b 100644 --- a/lib/accounts.rb +++ b/lib/accounts.rb @@ -34,6 +34,7 @@ module Accounts new_template = Template.find(1).dup new_template.account_id = account.id new_template.slug = SecureRandom.base58(14) + new_template.folder = account.default_template_folder new_template.save! diff --git a/lib/template_folders.rb b/lib/template_folders.rb new file mode 100644 index 00000000..9a3738ea --- /dev/null +++ b/lib/template_folders.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module TemplateFolders + module_function + + def search(folders, keyword) + return folders if keyword.blank? + + folders.where(TemplateFolder.arel_table[:name].lower.matches("%#{keyword.downcase}%")) + end + + def find_or_create_by_name(author, name) + return author.account.default_template_folder if name.blank? || name == TemplateFolder::DEFAULT_NAME + + author.account.template_folders.create_with(author:, account: author.account).find_or_create_by(name:) + end +end