Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PluginGemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ PLATFORMS
arm64-darwin-22
arm64-darwin-23
arm64-darwin-24
arm64-darwin-25

DEPENDENCIES
brakeman (~> 6.1)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Administration => Roles & Permissions
- Lint: `rake lint`
- Test: `rake test`
- Watch Assets: `rake watch`
- Database seeding: `rails redmine:sample_data:seed`

## Time Tracking Flow

Expand Down
8 changes: 8 additions & 0 deletions app/components/timer_session_entry_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
data: { confirm: t(:text_are_you_sure), timer_session_destroy_button: '' },
method: :delete, class: 'icon-only icon-del') %>
<% end %>
<%= link_to(sprite_icon('link', t('timer_sessions.timer.share'), icon_only: true),
share_url,
data: { controller: 'share-link',
action: 'click->share-link#copy',
share_link_copied_message_value: t('timer_sessions.timer.share_copied'),
timer_session_share_button: '',
session_id: timer_session_entry.id },
class: 'icon-only icon-link') %>
<% if display_discrepancy_errors? %>
<%= link_to(sprite_icon('warning', t('timer_sessions.messaging.errors.discrepancy_in_time_sum.title'), icon_only: true),
timer_sessions_time_error_path(timer_session_entry),
Expand Down
11 changes: 11 additions & 0 deletions app/components/timer_session_entry_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ def can_continue_entry?
User.current.allowed_to_globally?(action: :continue, controller: 'timer_sessions')
end

def share_url
datetime_format = I18n.t('timer_sessions.formats.datetime_format')
params = {
issue_ids: @timer_session_entry.issues.map(&:id),
comments: @timer_session_entry.comments,
timer_start: @timer_session_entry.timer_start&.strftime(datetime_format),
timer_end: @timer_session_entry.timer_end&.strftime(datetime_format)
}.compact_blank
timer_sessions_path(params)
end

def row_classes
classes = []
classes << 'error-block' if display_discrepancy_errors?
Expand Down
41 changes: 39 additions & 2 deletions app/controllers/timer_sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# frozen_string_literal: true

class TimerSessionsController < TrackyController
class TimerSessionsController < TrackyController # rubocop:disable Metrics/ClassLength
before_action :redirect_share_params, only: :index

def index
@timer_sessions_in_range = TimerSession.includes(:time_entries, :timer_session_time_entries, issues: :project)
.finished.created_by(User.current)
@non_matching_timer_session_ids = TimeDiscrepancyLoader.uneven_timer_session_ids(@timer_sessions_in_range)
set_timer_sessions
@timer_offset = offset_for_time_zone
@current_timer_session = TimerSession.active.find_by(user: User.current) || TimerSession.new
@current_timer_session = build_current_timer_session
end

def report
Expand Down Expand Up @@ -114,4 +116,39 @@ def filter_params

params[:filter].permit(:min_date, :max_date).to_h
end

def share_attrs
params.permit(:comments, :timer_start, :timer_end).to_h
end

def share_params_present?
share_attrs.values.any?(&:present?) || params[:issue_ids].present?
end

def redirect_share_params
return unless share_params_present?

if TimerSession.active.exists?(user: User.current)
flash[:warning] = t('timer_sessions.timer.share_ignored')
else
stash_share_for_redirect
end
redirect_to timer_sessions_path(filter: params[:filter])
end

def stash_share_for_redirect
flash[:notice] = t('timer_sessions.timer.share_prefilled')
session[:pending_share] = share_attrs.merge(issue_ids: Array(params[:issue_ids]))
end

def build_current_timer_session
active = TimerSession.active.find_by(user: User.current)
return active if active

attrs = session.delete(:pending_share) || {}
new_session = TimerSession.new(attrs.except('issue_ids', :issue_ids))
ids = attrs['issue_ids'] || attrs[:issue_ids]
new_session.issues = Issue.visible.where(id: ids) if ids.present?
new_session
end
end
29 changes: 20 additions & 9 deletions app/views/timer_sessions/_timer_container.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<div class="box" data-controller="form timer" data-timer-timezone-value="<%= offset_for_time_zone %>">
<div class="box" data-controller="form timer" data-timer-timezone-value="<%= offset_for_time_zone %>"
data-form-share-copied-message-value="<%= t('timer_sessions.timer.share_copied') %>">
<div class="timer-container">
<% active_timer_session = timer_session %>
<%= labelled_form_for(active_timer_session,
Expand Down Expand Up @@ -78,16 +79,26 @@
<%= t('timer_sessions.timer.cancel') %>
<% end %>
<% end %>
<%= f.button :share, type: :submit, data: { name: 'timer-share', action: 'click->form#share' } do %>
<%= t('timer_sessions.timer.share') %>
<%= sprite_icon('link') %>
<% end %>
</div>
<% elsif !timer_session.persisted? && User.current.allowed_to_globally?(action: :create, controller: 'time_tracker') %>
<%= f.button :start, type: :submit, value: :start, data: { name: 'timer-start' }, name: :commit do %>
<%= t('timer_sessions.timer.start') %>
<%= sprite_icon('add') %>
<% end %>
<%= f.button :start, type: :submit, value: :continue_last_session, data: { name: 'timer-continue' }, class: 'ml-3', name: :commit do %>
<%= t('timer_sessions.timer.continue_last_session') %>
<%= sprite_icon('add') %>
<% end %>
<div class="starting-action-buttons">
<%= f.button :start, type: :submit, value: :start, data: { name: 'timer-start' }, name: :commit do %>
<%= t('timer_sessions.timer.start') %>
<%= sprite_icon('add') %>
<% end %>
<%= f.button :start, type: :submit, value: :continue_last_session, data: { name: 'timer-continue' }, name: :commit do %>
<%= t('timer_sessions.timer.continue_last_session') %>
<%= sprite_icon('add') %>
<% end %>
<%= f.button :share, type: :submit, data: { name: 'timer-share', action: 'click->form#share' } do %>
<%= t('timer_sessions.timer.share') %>
<%= sprite_icon('link') %>
<% end %>
</div>
<% end %>
<% end %>

Expand Down
2 changes: 2 additions & 0 deletions assets.src/src/redmine-tracky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import TimerController from '@controllers/timer-controller'
import ListController from '@controllers/list-controller'
import IssueCompletionController from '@controllers/issue-completion-controller'
import FlatpickrController from '@controllers/flatpickr-controller'
import ShareLinkController from '@controllers/share-link-controller'

window.Stimulus = Application.start()
window.Stimulus.register('form', FormController)
window.Stimulus.register('timer', TimerController)
window.Stimulus.register('list', ListController)
window.Stimulus.register('issue-completion', IssueCompletionController)
window.Stimulus.register('flatpickr', FlatpickrController)
window.Stimulus.register('share-link', ShareLinkController)
41 changes: 41 additions & 0 deletions assets.src/src/redmine-tracky/controllers/form-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default class extends Controller {
declare readonly absolutInputTarget: HTMLInputElement
declare readonly descriptionTarget: HTMLInputElement
declare readonly issueTargets: Element[]
declare readonly shareCopiedMessageValue: string

private connected = false

Expand All @@ -21,6 +22,10 @@ export default class extends Controller {
'absolutInput',
]

static values = {
shareCopiedMessage: String,
}

public connect() {
this.connected = true
}
Expand Down Expand Up @@ -67,6 +72,42 @@ export default class extends Controller {
this.dispatchUpdate(form)
}

public share(event: Event) {
Comment thread
cb341 marked this conversation as resolved.
event.preventDefault()

const params = new URLSearchParams()
this.extractIssueIds().forEach((id) => params.append('issue_ids[]', id))
if (this.descriptionTarget.value) params.set('comments', this.descriptionTarget.value)
if (this.startTarget.value) params.set('timer_start', this.startTarget.value)
if (this.endTarget.value) params.set('timer_end', this.endTarget.value)

const url = new URL(window.location.pathname, window.location.origin)
url.search = params.toString()

navigator.clipboard.writeText(url.toString()).then(() => {
this.showFlash(this.shareCopiedMessageValue)
})
}

private showFlash(message: string) {
const flashId = 'flash_notice'
const existing = document.getElementById(flashId)
if (existing) {
existing.textContent = message
existing.style.display = ''
return
}

const container = document.getElementById('content')
if (!container) return

const flash = document.createElement('div')
flash.id = flashId
flash.className = 'flash notice'
flash.textContent = message
container.prepend(flash)
}

private extractIssueIds(): string[] {
return (
this.issueTargets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export default class extends Controller {

connect() {
this.listenForInput()
this.fetchIssuesFromURL()
}

private listenForInput() {
Expand All @@ -43,25 +42,6 @@ export default class extends Controller {
)
}

private fetchIssuesFromURL() {
const urlParams = new URLSearchParams(window.location.search)
const issueIds = urlParams.getAll('issue_ids[]')

issueIds.filter(v => v !== "").forEach((id) => {
const url = window.RedmineTracky.issueCompletionPath
const data = { term: id, scope: 'all' }

$.get(url, data, null, 'json')
.done((results: CompletionResult[]) => {
const [result] = results
this.addIssue({ item: result })
})
.fail(() => {
console.error(`Failed to fetch issue with ID: ${id}`)
})
})
}

private addIssue(issue: { item: CompletionResult }) {
const listController =
this.application.getControllerForElementAndIdentifier(
Expand Down
38 changes: 38 additions & 0 deletions assets.src/src/redmine-tracky/controllers/share-link-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Controller } from '@hotwired/stimulus'

export default class extends Controller<HTMLAnchorElement> {
declare readonly copiedMessageValue: string

static values = {
copiedMessage: String,
}

public copy(event: Event) {
event.preventDefault()
const href = this.element.href
const url = new URL(href, window.location.origin).toString()

navigator.clipboard.writeText(url).then(() => {
this.showFlash(this.copiedMessageValue)
})
}

private showFlash(message: string) {
const flashId = 'flash_notice'
const existing = document.getElementById(flashId)
if (existing) {
existing.textContent = message
existing.style.display = ''
return
}

const container = document.getElementById('content')
if (!container) return

const flash = document.createElement('div')
flash.id = flashId
flash.className = 'flash notice'
flash.textContent = message
container.prepend(flash)
Comment on lines +32 to +36
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the downside of having the prefill logic in the frontend.
We need to duplicate logic for rendering flashes.

Luckily it is not that much code.

}
}
6 changes: 6 additions & 0 deletions assets.src/src/styles/timer_container.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
column-count: 2;
}

.starting-action-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}

.timer-container button svg {
stroke: #fff;
}
Expand Down
2 changes: 1 addition & 1 deletion assets/javascripts/redmine-tracky.js

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions config/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ de:
timer:
start: Start
continue_last_session: Anschliessend Starten
share: Teilen
share_copied: Link in die Zwischenablage kopiert
share_prefilled: Session wurde von einem geteilten Link vorausgefüllt
share_ignored: Ein Timer läuft bereits. Die geteilten Parameter wurden ignoriert.
stop: Stop
cancel: Abbrechen
date_placeholder: 'dd.mm.yyyy hh:mm'
Expand Down
4 changes: 4 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ en:
timer:
start: Start
continue_last_session: Start on end of last session
share: Share
share_copied: Link copied to clipboard
share_prefilled: Session was prefilled from a shared link
share_ignored: A timer is already running. Shared session parameters were ignored.
stop: Stop
cancel: Cancel
date_placeholder: 'dd.mm.yyyy hh:mm'
Expand Down
Loading
Loading