feat(NcDatetime): Add new component for displaying a timestamp as time from now

This implements a component showing for displaying timestamps like *x seconds ago*
without the need of huge libraries like moment.js

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2023-06-15 14:14:48 +02:00
Родитель 076e2dc730
Коммит 9bacc9861c
6 изменённых файлов: 507 добавлений и 5 удалений

Просмотреть файл

@ -8,6 +8,9 @@ msgstr ""
msgid "{tag} (restricted)"
msgstr ""
msgid "a few seconds ago"
msgstr ""
msgid "Actions"
msgstr ""
@ -179,6 +182,14 @@ msgstr ""
msgid "Search results"
msgstr ""
#. FOR TRANSLATORS: If possible in your language an even shorter version of 'a few seconds ago'
msgid "sec. ago"
msgstr ""
#. FOR TRANSLATORS: Shorter version of 'a few seconds ago'
msgid "seconds ago"
msgstr ""
msgid "Select a tag"
msgstr ""

Просмотреть файл

@ -21,15 +21,15 @@
"l10n:extract": "node build/extract-l10n.js",
"lint": "eslint --ext .js,.vue src",
"lint:fix": "eslint --ext .js,.vue src --fix",
"test": "jest --verbose",
"test:coverage": "jest --verbose --coverage --no-cache",
"test": "TZ=UTC jest --verbose",
"test:coverage": "TZ=UTC jest --verbose --coverage --no-cache",
"stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css",
"stylelint:fix": "stylelint src/**/*.vue src/**/*.scss src/**/*.css --fix",
"styleguide": "vue-styleguidist server",
"styleguide:build": "vue-styleguidist build",
"cypress": "cypress run --component",
"cypress:gui": "cypress open --component",
"cypress:update-snapshots": "cypress run --component --spec cypress/visual/**/*.cy.js --env type=base --config screenshotsFolder=cypress/snapshots/base"
"cypress": "TZ=UTC cypress run --component",
"cypress:gui": "TZ=UTC cypress open --component",
"cypress:update-snapshots": "TZ=UTC cypress run --component --spec cypress/visual/**/*.cy.js --env type=base --config screenshotsFolder=cypress/snapshots/base"
},
"main": "dist/ncvuecomponents.js",
"module": "dist/index.module.js",

Просмотреть файл

@ -0,0 +1,258 @@
<!--
- @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
-
- @author Ferdinand Thiessen <opensource@fthiessen.de>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<docs>
### General description
This components purpose is to display a timestamp in the users local time format.
It also supports relative time, for examples *6 seconds ago*.
#### Standard usage
Without any optional parameters the timestamp is displayed as a relative datetime and a title with the full date is added.
```vue
<template>
<NcDatetime :timestamp="timestamp" />
</template>
<script>
export default {
data() {
return {
timestamp: Date.now(),
}
},
}
</script>
```
#### Ignore seconds
If you do not want the seconds to be counted up until minutes are reached you can simply use the `ignore-seconds` property.
```vue
<template>
<NcDatetime :timestamp="timestamp" :ignore-seconds="true" />
</template>
<script>
export default {
data() {
return {
timestamp: Date.now(),
}
},
}
</script>
```
#### Custom date or time format
The component allows to format the full date for the title by settings the `format` property.
It is also possible to disable relative time by setting the `relativeTime` property to `false`.
```vue
<template>
<div>
<h4>Short relative time</h4>
<NcDatetime :timestamp="timestamp" relative-time="short" />
<h4>Custom title format</h4>
<NcDatetime :timestamp="timestamp" :format="timeFormat" />
<h4>Without relative time</h4>
<NcDatetime :timestamp="timestamp" :format="timeFormat" :relative-time="false" />
</div>
</template>
<script>
export default {
data() {
return {
timestamp: Date.now(),
/** For allowed formats see the Intl.DateTimeFormat options */
timeFormat: {
dateStyle: 'short',
timeStyle: 'full'
},
}
},
}
</script>
<style>
h4 {
font-weight: bold;
margin-top: 12px;
}
</style>
```
</docs>
<template>
<span class="nc-datetime"
:data-timestamp="timestamp"
:title="formattedFullTime">{{ formattedTime }}</span>
</template>
<script>
import { getCanonicalLocale } from '@nextcloud/l10n'
import { t } from '../../l10n.js'
const FEW_SECONDS_AGO = {
long: t('a few seconds ago'),
short: t('seconds ago'), // FOR TRANSLATORS: Shorter version of 'a few seconds ago'
narrow: t('sec. ago'), // FOR TRANSLATORS: If possible in your language an even shorter version of 'a few seconds ago'
}
export default {
name: 'NcDatetime',
props: {
/**
* The timestamp to display, either an unix timestamp (in milliseconds) or a Date object
*/
timestamp: {
type: [Date, Number],
required: true,
},
/**
* The format used for displaying, or if relative time is used the format used for the title (optional)
*
* @type {Intl.DateTimeFormatOptions}
*/
format: {
type: Object,
default: () => ({ timeStyle: 'medium', dateStyle: 'short' }),
},
/**
* Wether to display the timestamp as time from now (optional)
*
* - `false`: Disable relative time
* - `'long'`: Long text, like *2 seconds ago* (default)
* - `'short'`: Short text, like *2 sec. ago*
* - `'narrow'`: Even shorter text (same as `'short'` on some languages)
*/
relativeTime: {
type: [Boolean, String],
default: 'long',
validator: (v) => v === false || ['long', 'short', 'narrow'].includes(v),
},
/**
* Ignore seconds when displaying the relative time and just show `a few seconds ago`
*/
ignoreSeconds: {
type: Boolean,
default: false,
},
},
data() {
return {
/** Current time in ms */
currentTime: Date.now(),
/** ID of the current time interval */
intervalId: undefined,
}
},
computed: {
/** ECMA Date object of the timestamp */
dateObject() {
return new Date(this.timestamp)
},
/** Time string formatted for main text */
formattedTime() {
if (this.relativeTime !== false) {
const formatter = new Intl.RelativeTimeFormat(getCanonicalLocale(), { numeric: 'auto', style: this.relativeTime })
const diff = this.dateObject - new Date(this.currentTime)
const seconds = diff / 1000
if (Math.abs(seconds) <= 90) {
if (this.ignoreSeconds) {
return FEW_SECONDS_AGO[this.relativeTime]
} else {
return formatter.format(Math.round(seconds), 'second')
}
}
const minutes = seconds / 60
if (Math.abs(minutes) <= 90) {
return formatter.format(Math.round(minutes), 'minute')
}
const hours = minutes / 60
if (Math.abs(hours) <= 72) {
return formatter.format(Math.round(hours), 'hour')
}
const days = hours / 24
if (Math.abs(days) <= 6) {
return formatter.format(Math.round(days), 'day')
}
const weeks = days / 7
if (Math.abs(weeks) <= 52) {
return formatter.format(Math.round(weeks), 'week')
}
return formatter.format(Math.round(days / 365), 'year')
}
return this.formattedFullTime
},
formattedFullTime() {
const formatter = new Intl.DateTimeFormat(getCanonicalLocale(), this.format)
return formatter.format(this.dateObject)
},
},
watch: {
/**
* Set or clear interval if relative time is dis/enabled
*
* @param {boolean} newValue The new value of the relativeTime property
* @param {boolean} _oldValue The old value of the relativeTime property
*/
relativeTime(newValue, _oldValue) {
window.clearInterval(this.intervalId)
this.intervalId = undefined
if (newValue) {
this.intervalId = window.setInterval(this.setCurrentTime, 1000)
}
},
},
mounted() {
// Start the interval for setting the current time if relative time is enabled
if (this.relativeTime !== false) {
this.intervalId = window.setInterval(this.setCurrentTime, 1000)
}
},
destroyed() {
// ensure interval is cleared
window.clearInterval(this.intervalId)
},
methods: {
/**
* Set `currentTime` to the current timestamp, required as Date.now() is not reactive.
*/
setCurrentTime() {
this.currentTime = Date.now()
},
},
}
</script>

Просмотреть файл

@ -0,0 +1,23 @@
/**
* @copyright 2023 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
export { default } from './NcDatetime.vue'

Просмотреть файл

@ -57,6 +57,7 @@ export { default as NcContent } from './NcContent/index.js'
export { default as NcCounterBubble } from './NcCounterBubble/index.js'
export { default as NcDashboardWidget } from './NcDashboardWidget/index.js'
export { default as NcDashboardWidgetItem } from './NcDashboardWidgetItem/index.js'
export { default as NcDatetime } from './NcDatetime/index.js'
export { default as NcDatetimePicker } from './NcDatetimePicker/index.js'
export { default as NcDateTimePickerNative } from './NcDateTimePickerNative/index.js'
// Not exported on purpose

Просмотреть файл

@ -0,0 +1,209 @@
/**
* @copyright 2023 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { mount } from '@vue/test-utils'
import NcDatetime from '../../../../src/components/NcDatetime/NcDatetime.vue'
describe('NcDatetime.vue', () => {
'use strict'
it('Sets the title property correctly', () => {
const time = Date.UTC(2023, 5, 23, 14, 30)
Date.now = jest.fn(() => new Date(time).valueOf())
const wrapper = mount(NcDatetime, {
propsData: {
timestamp: time,
},
})
expect(wrapper.element.hasAttribute('title')).toBe(true)
expect(wrapper.element.getAttribute('title')).toMatch('6/23/23, 2:30:00 PM')
})
it('Can set format of the title property', () => {
const time = Date.UTC(2023, 5, 23, 14, 30)
Date.now = jest.fn(() => new Date(time).valueOf())
const wrapper = mount(NcDatetime, {
propsData: {
timestamp: time,
format: { dateStyle: 'long' },
},
})
expect(wrapper.element.hasAttribute('title')).toBe(true)
expect(wrapper.element.getAttribute('title')).toMatch('June 23, 2023')
})
it('Can disable relative time', () => {
const time = Date.UTC(2023, 5, 23, 14, 30)
Date.now = jest.fn(() => new Date(time).valueOf())
const wrapper = mount(NcDatetime, {
propsData: {
timestamp: time,
relativeTime: false,
},
})
expect(wrapper.element.hasAttribute('title')).toBe(true)
expect(wrapper.element.textContent).toMatch(wrapper.element.getAttribute('title'))
})
describe('Work with different locales', () => {
beforeAll(() => {
// mock the locale
document.documentElement.dataset.locale = 'de_DE'
})
afterAll(() => {
// revert mock
document.documentElement.dataset.locale = 'en'
})
/**
* Use German locale as it uses a different date format than English
*/
it('', () => {
const time = Date.UTC(2023, 5, 23, 14, 30)
Date.now = jest.fn(() => new Date(time).valueOf())
const wrapper = mount(NcDatetime, {
propsData: {
timestamp: time,
},
})
expect(wrapper.element.hasAttribute('title')).toBe(true)
expect(wrapper.element.getAttribute('title')).toMatch('23.06.23, 14:30:00')
})
})
describe('Shows relative time', () => {
it('works with currentTime == timestamp', () => {
const time = Date.UTC(2023, 5, 23, 14, 30)
Date.now = jest.fn(() => new Date(time).valueOf())
const wrapper = mount(NcDatetime, {
propsData: {
timestamp: time,
},
})
expect(wrapper.vm.currentTime).toEqual(time)
expect(wrapper.element.textContent).toContain('now')
})
it('shows seconds from now (updating)', async () => {
const time = Date.UTC(2023, 5, 23, 14, 30, 30)
let currentTime = Date.UTC(2023, 5, 23, 14, 30, 33)
Date.now = jest.fn(() => new Date(currentTime).valueOf())
const wrapper = mount(NcDatetime, {
propsData: {
timestamp: time,
},
})
expect(wrapper.vm.currentTime).toEqual(currentTime)
expect(wrapper.element.textContent).toContain('3 seconds')
currentTime = Date.UTC(2023, 5, 23, 14, 30, 34)
// wait for timer
await new Promise((resolve) => setTimeout(resolve, 1100))
expect(wrapper.element.textContent).toContain('4 seconds')
})
it('shows seconds from now - also as short variant', () => {
const time = Date.UTC(2023, 5, 23, 14, 30, 30)
const currentTime = Date.UTC(2023, 5, 23, 14, 30, 33)
Date.now = jest.fn(() => new Date(currentTime).valueOf())
const wrapper = mount(NcDatetime, {
propsData: {
timestamp: time,
relativeTime: 'short',
},
})
expect(wrapper.vm.currentTime).toEqual(currentTime)
expect(wrapper.element.textContent).toContain('3 sec.')
})
it('shows minutes from now', () => {
const time = Date.UTC(2023, 5, 23, 14, 30, 30)
const currentTime = Date.UTC(2023, 5, 23, 14, 33, 30)
Date.now = jest.fn(() => new Date(currentTime).valueOf())
const wrapper = mount(NcDatetime, {
propsData: {
timestamp: time,
},
})
expect(wrapper.vm.currentTime).toEqual(currentTime)
expect(wrapper.element.textContent).toContain('3 minutes')
})
it('shows hours from now', () => {
const time = Date.UTC(2023, 5, 23, 14, 30, 30)
const currentTime = Date.UTC(2023, 5, 23, 17, 30, 30)
Date.now = jest.fn(() => new Date(currentTime).valueOf())
const wrapper = mount(NcDatetime, {
propsData: {
timestamp: time,
},
})
expect(wrapper.vm.currentTime).toEqual(currentTime)
expect(wrapper.element.textContent).toContain('3 hours')
})
it('shows weeks from now', () => {
const time = Date.UTC(2023, 5, 23, 14, 30, 30)
const currentTime = Date.UTC(2023, 6, 13, 14, 30, 30)
Date.now = jest.fn(() => new Date(currentTime).valueOf())
const wrapper = mount(NcDatetime, {
propsData: {
timestamp: time,
},
})
expect(wrapper.vm.currentTime).toEqual(currentTime)
expect(wrapper.element.textContent).toContain('3 weeks')
})
it('shows years from now', () => {
const time = Date.UTC(2023, 5, 23, 14, 30, 30)
const time2 = Date.UTC(2022, 5, 23, 14, 30, 30)
const currentTime = Date.UTC(2024, 6, 13, 14, 30, 30)
Date.now = jest.fn(() => new Date(currentTime).valueOf())
const wrapper = mount(NcDatetime, {
propsData: {
timestamp: time,
},
})
const wrapper2 = mount(NcDatetime, {
propsData: {
timestamp: time2,
},
})
expect(wrapper.vm.currentTime).toEqual(currentTime)
expect(wrapper2.vm.currentTime).toEqual(currentTime)
expect(wrapper.element.textContent).toContain('last year')
expect(wrapper2.element.textContent).toContain('2 years')
})
})
})