Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reimplement timeout countdown as custom element #6023

Merged
merged 10 commits into from
Mar 15, 2022
Merged
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
2 changes: 1 addition & 1 deletion app/components/base_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def before_render
end

def self.scripts
@scripts ||= _sidecar_files(['js']).map { |file| File.basename(file, '.js') }
@scripts ||= _sidecar_files(['js', 'ts']).map { |file| File.basename(file, '.*') }
end

def unique_id
Expand Down
38 changes: 38 additions & 0 deletions app/components/countdown_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
class CountdownComponent < BaseComponent
attr_reader :expiration, :update_interval, :start_immediately, :tag_options

MILLISECONDS_PER_SECOND = 1000

def initialize(
expiration:,
update_interval: 1.second,
start_immediately: true,
**tag_options
)
@expiration = expiration
@update_interval = update_interval
@start_immediately = start_immediately
@tag_options = tag_options
end

def call
content_tag(
:'lg-countdown',
time_remaining,
**tag_options,
data: {
expiration: expiration.iso8601,
update_interval: update_interval_in_ms,
start_immediately: start_immediately,
}.merge(tag_options[:data].to_h),
)
end

def update_interval_in_ms
update_interval.in_seconds * MILLISECONDS_PER_SECOND
end

def time_remaining
distance_of_time_in_words(Time.zone.now, expiration, true)
end
end
3 changes: 3 additions & 0 deletions app/components/countdown_component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { CountdownElement } from '@18f/identity-countdown-element';

customElements.define('lg-countdown', CountdownElement);
12 changes: 6 additions & 6 deletions app/helpers/session_timeout_warning_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,22 @@ def session_timeout_warning
IdentityConfig.store.session_timeout_warning_seconds
end

def expires_at
session[:session_expires_at]&.to_datetime || Time.zone.now - 1
end

def timeout_refresh_path
UriService.add_params(
request.original_fullpath,
timeout: true,
)&.html_safe # rubocop:disable Rails/OutputSafety
end

def time_left_in_session
distance_of_time_in_words(session_timeout_warning, 0)
end

def session_modal
if user_fully_authenticated?
FullySignedInModalPresenter.new(time_left_in_session)
FullySignedInModalPresenter.new(view_context: self, expiration: expires_at)
else
PartiallySignedInModalPresenter.new(time_left_in_session)
PartiallySignedInModalPresenter.new(view_context: self, expiration: expires_at)
end
end
end
32 changes: 0 additions & 32 deletions app/javascript/app/utils/countdown-timer.js

This file was deleted.

8 changes: 0 additions & 8 deletions app/javascript/app/utils/index.js

This file was deleted.

20 changes: 0 additions & 20 deletions app/javascript/app/utils/ms-formatter.js

This file was deleted.

118 changes: 118 additions & 0 deletions app/javascript/packages/countdown-element/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import sinon from 'sinon';
import { i18n } from '@18f/identity-i18n';
import { usePropertyValue } from '@18f/identity-test-helpers';
import { CountdownElement } from './index';

const DEFAULT_DATASET = {
updateInterval: '1000',
startImmediately: 'true',
expiration: new Date().toISOString(),
};

describe('CountdownElement', () => {
let clock: sinon.SinonFakeTimers;

usePropertyValue(i18n, 'strings', {
'datetime.dotiw.seconds': { one: 'one second', other: '%{count} seconds' },
'datetime.dotiw.minutes': { one: 'one minute', other: '%{count} minutes' },
'datetime.dotiw.two_words_connector': ' and ',
});

before(() => {
if (!customElements.get('lg-countdown')) {
customElements.define('lg-countdown', CountdownElement);
}

clock = sinon.useFakeTimers();
});

after(() => {
clock.restore();
});

function createElement(dataset = {}) {
const element = document.createElement('lg-countdown') as CountdownElement;
Object.assign(element.dataset, DEFAULT_DATASET, dataset);
document.body.appendChild(element);
return element;
}

it('sets text to formatted date', () => {
const element = createElement({
expiration: new Date(new Date().getTime() + 62000).toISOString(),
});

expect(element.textContent).to.equal('one minute and 2 seconds');
});

it('schedules update after interval', () => {
const element = createElement({
expiration: new Date(new Date().getTime() + 3000).toISOString(),
updateInterval: '2000',
});

clock.tick(1999);

expect(element.textContent).to.equal('0 minutes and 3 seconds');

clock.tick(1);

expect(element.textContent).to.equal('0 minutes and one second');
});

it('allows a delayed start', () => {
const element = createElement({
expiration: new Date(new Date().getTime() + 1000).toISOString(),
startImmediately: 'false',
});

clock.tick(1000);

expect(element.textContent).to.equal('0 minutes and one second');

element.start();

expect(element.textContent).to.equal('0 minutes and 0 seconds');
});

it('can be stopped and restarted', () => {
const element = createElement({
expiration: new Date(new Date().getTime() + 2000).toISOString(),
updateInterval: '1000',
});

element.stop();
clock.tick(1000);

expect(element.textContent).to.equal('0 minutes and 2 seconds');

element.start();

expect(element.textContent).to.equal('0 minutes and one second');
});

it('updates in response to changed expiration', () => {
const element = createElement();

element.expiration = new Date(new Date().getTime() + 1000);

expect(element.textContent).to.equal('0 minutes and one second');

element.setAttribute('data-expiration', new Date(new Date().getTime() + 2000).toISOString());

expect(element.textContent).to.equal('0 minutes and 2 seconds');
});

describe('#start', () => {
it('is idempotent', () => {
const element = createElement({ startImmediately: 'false' });

sinon.spy(element, 'setTimeRemaining');

element.start();
element.start();

expect(clock.countTimers()).to.equal(1);
});
});
});
70 changes: 70 additions & 0 deletions app/javascript/packages/countdown-element/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { t } from '@18f/identity-i18n';

export class CountdownElement extends HTMLElement {
#pollIntervalId?: number;

static observedAttributes = ['data-expiration'];

connectedCallback() {
if (this.startImmediately) {
this.start();
} else {
this.setTimeRemaining();
}
}

disconnectedCallback() {
this.stop();
}

attributeChangedCallback() {
this.setTimeRemaining();
}

get expiration(): Date {
return new Date(this.getAttribute('data-expiration')!);
}

set expiration(expiration: Date) {
this.setAttribute('data-expiration', expiration.toISOString());
}

get timeRemaining(): number {
return Math.max(this.expiration.getTime() - Date.now(), 0);
}

get updateInterval(): number {
return Number(this.getAttribute('data-update-interval'));
}

get startImmediately(): boolean {
return this.getAttribute('data-start-immediately') === 'true';
}

get #textNode(): Text {
if (!this.firstChild) {
this.appendChild(this.ownerDocument.createTextNode(''));
}

return this.firstChild as Text;
}

start(): void {
this.stop();
this.setTimeRemaining();
this.#pollIntervalId = window.setInterval(() => this.setTimeRemaining(), this.updateInterval);
}

stop(): void {
window.clearInterval(this.#pollIntervalId);
}

setTimeRemaining(): void {
const { timeRemaining } = this;

this.#textNode.nodeValue = [
t('datetime.dotiw.minutes', { count: Math.floor(timeRemaining / 60000) }),
t('datetime.dotiw.seconds', { count: Math.floor(timeRemaining / 1000) % 60 }),
].join(t('datetime.dotiw.two_words_connector'));
}
}
5 changes: 5 additions & 0 deletions app/javascript/packages/countdown-element/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "@18f/identity-countdown-element",
"private": true,
"version": "1.0.0"
}
1 change: 0 additions & 1 deletion app/javascript/packs/application.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
require('../app/components/index');
require('../app/utils/index');
require('../app/pw-toggle');
require('../app/print-personal-key');
require('../app/i18n-dropdown');
Expand Down
Loading