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: 2 additions & 0 deletions resources/js/bootstrap/components.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ import AssetContainerList from '../components/AssetContainerList.vue';
import AddonList from '../components/AddonList.vue';
import AddonDetails from '../components/AddonDetails.vue';
import CollectionWidget from '../components/entries/CollectionWidget.vue';
import FormWidget from '../components/forms/FormWidget.vue';
import SvgIcon from '../components/SvgIcon.vue';
import FileIcon from '../components/FileIcon.vue';
import LoadingGraphic from '../components/LoadingGraphic.vue';
Expand DownExpand Up@@ -115,6 +116,7 @@ export default function registerGlobalComponents(app) {

// Widgets
app.component('collection-widget', CollectionWidget);
app.component('form-widget', FormWidget);

// Reusable
app.component('svg-icon', SvgIcon);
Expand Down
54 changes: 54 additions & 0 deletions resources/js/components/DateFormatter.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,12 +35,66 @@ export default class DateFormatter {

toString() {
try {
if (this.#options.relative) return this.#formatRelative();

return Intl.DateTimeFormat(this.locale, this.#options).format(this.#date);
} catch (e) {
return 'Invalid Date';
}
}

#formatRelative() {
let specificity = this.#options.relative;
specificity = !specificity || specificity === true ? 'year' : specificity;

const now = new Date();
const diff = now - this.#date;
const seconds = Math.abs(Math.floor(diff / 1000));
const minutes = Math.abs(Math.floor(seconds / 60));
const hours = Math.abs(Math.floor(minutes / 60));
const days = Math.abs(Math.floor(hours / 24));
const weeks = Math.abs(Math.floor(days / 7));
const months = Math.abs(Math.floor(days / 30));
const years = Math.abs(Math.floor(days / 365));

const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric: 'auto' });

// Always show seconds if less than a minute
if (seconds < 60) return rtf.format(-Math.sign(diff) * seconds, 'second');

// Show minutes if less than an hour and specificity allows
if (minutes < 60 && ['minute', 'hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * minutes, 'minute');
}

// Show hours if less than a day and specificity allows
if (hours < 24 && ['hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * hours, 'hour');
}

// Show days if less than a week and specificity allows
if (days < 7 && ['day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * days, 'day');
}

// Show weeks if less than a month and specificity allows
if (weeks < 4 && ['week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * weeks, 'week');
}

// Show months if less than a year and specificity allows
if (months < 12 && ['month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * months, 'month');
}

// Show years if specificity allows
if (specificity === 'year') {
return rtf.format(-Math.sign(diff) * years, 'year');
}

return new DateFormatter(this.#date, this.#options.fallback || 'datetime').toString();
}

static format(date, options) {
return new DateFormatter(date, options).toString();
}
Expand Down
82 changes: 82 additions & 0 deletions resources/js/components/forms/FormWidget.vue
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
<script>
import Listing from '../Listing.vue';
import DateFormatter from '@statamic/components/DateFormatter.js';
import { Widget } from '@statamic/ui';

export default {
mixins: [Listing],

components: {
Widget,
},

props: {
form: { type: String, required: true },
fields: { type: Array, default: () => [] },
title: { type: String },
},

data() {
return {
cols: [
...this.fields.map((field) => ({ label: field, field, visible: true })),
{ label: 'Date', field: 'date', visible: true },
],
listingKey: 'submissions',
requestUrl: cp_url(`forms/${this.form}/submissions`),
};
},

methods: {
formatDate(value) {
return DateFormatter.format(value, { relative: 'hour' }).toString();
},
},
};
</script>

<template>
<Widget :title="title" icon="forms">
<data-list v-if="!initializing && items.length" :rows="items" :columns="cols" :sort="false" class="w-full">
<div v-if="initializing" class="loading">
<loading-graphic />
</div>

<data-list-table
v-else
:loading="loading"
unstyled
class="[&_td]:px-0.5 [&_td]:py-0.75 [&_td]:text-sm [&_thead]:hidden"
>
<template v-for="field in fields" #[`cell-${field}`]="{ row: submission }">
<a
:href="cp_url(`forms/${form}/submissions/${submission.id}`)"
class="line-clamp-1 overflow-hidden text-ellipsis"
>
{{ submission[field] }}
</a>
</template>
<template #cell-date="{ row: submission }">
<div
class="text-end font-mono text-xs whitespace-nowrap text-gray-500 antialiased"
v-html="formatDate(submission.datestamp)"
/>
</template>
</data-list-table>
</data-list>

<p v-if="!initializing && !items.length" class="p-3 text-center text-sm text-gray-600">
{{ __('This form is awaiting responses') }}
</p>

<template #actions>
<data-list-pagination
v-if="meta.last_page != 1"
:resource-meta="meta"
@page-selected="selectPage"
:scroll-to-top="false"
:show-page-links="false"
/>
</template>
</Widget>
</template>
2 changes: 1 addition & 1 deletion resources/js/components/ui/Widget.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ defineProps({

<template>
<ui-card inset>
<div class="flex h-full flex-col justify-between">
<div class="flex h-full flex-col justify-between min-h-54">
<div>
<header class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
Expand Down
3 changes: 2 additions & 1 deletion resources/js/components/ui/index.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,5 +5,6 @@ import { default as DatePicker } from './Datepicker/Index.vue';
import { default as DateRangePicker } from './DateRangePicker/Index.vue';
import { default as TimePicker } from './TimePicker/Index.vue';
import { default as Button } from './Button/Index.vue';
import { default as Widget } from './Widget.vue';

export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button };
export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button, Widget };
103 changes: 103 additions & 0 deletions resources/js/tests/components/DateFormatter.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -208,3 +208,106 @@ test.each([
test('an invalid preset throws an error', () => {
expect(() => new DateFormatter().options('foo')).toThrow('Invalid date format: foo');
});

test.each([
// All the different diffs of time. It defaults to 'year' specificity.
['now', 'en', { relative: true }, '2021-12-25T12:13:14Z', 'now'],
['seconds ago', 'en', { relative: true }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['minute ago', 'en', { relative: true }, '2021-12-25T12:12:14Z', '1 minute ago'],
['minutes ago', 'en', { relative: true }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['hour ago', 'en', { relative: true }, '2021-12-25T11:13:14Z', '1 hour ago'],
['hours ago', 'en', { relative: true }, '2021-12-25T10:13:14Z', '2 hours ago'],
['day ago', 'en', { relative: true }, '2021-12-24T12:13:14Z', 'yesterday'],
['days ago', 'en', { relative: true }, '2021-12-23T12:13:14Z', '2 days ago'],
['week ago', 'en', { relative: true }, '2021-12-18T12:13:14Z', 'last week'],
['weeks ago', 'en', { relative: true }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['month ago', 'en', { relative: true }, '2021-11-25T12:13:14Z', 'last month'],
['months ago', 'en', { relative: true }, '2021-10-25T12:13:14Z', '2 months ago'],
['year ago', 'en', { relative: true }, '2020-12-25T12:13:14Z', 'last year'],
['years ago', 'en', { relative: true }, '2019-12-25T12:13:14Z', '2 years ago'],

// Same in another locale
['de, now', 'de', { relative: true }, '2021-12-25T12:13:14Z', 'jetzt'],
['de, seconds ago', 'de', { relative: true }, '2021-12-25T12:13:12Z', 'vor 2 Sekunden'],
['de, minute ago', 'de', { relative: true }, '2021-12-25T12:12:14Z', 'vor 1 Minute'],
['de, minutes ago', 'de', { relative: true }, '2021-12-25T12:11:14Z', 'vor 2 Minuten'],
['de, hour ago', 'de', { relative: true }, '2021-12-25T11:13:14Z', 'vor 1 Stunde'],
['de, hours ago', 'de', { relative: true }, '2021-12-25T10:13:14Z', 'vor 2 Stunden'],
['de, day ago', 'de', { relative: true }, '2021-12-24T12:13:14Z', 'gestern'],
['de, days ago', 'de', { relative: true }, '2021-12-23T12:13:14Z', 'vorgestern'],
['de, week ago', 'de', { relative: true }, '2021-12-18T12:13:14Z', 'letzte Woche'],
['de, weeks ago', 'de', { relative: true }, '2021-12-11T12:13:14Z', 'vor 2 Wochen'],
['de, month ago', 'de', { relative: true }, '2021-11-25T12:13:14Z', 'letzten Monat'],
['de, months ago', 'de', { relative: true }, '2021-10-25T12:13:14Z', 'vor 2 Monaten'],
['de, year ago', 'de', { relative: true }, '2020-12-25T12:13:14Z', 'letztes Jahr'],
['de, years ago', 'de', { relative: true }, '2019-12-25T12:13:14Z', 'vor 2 Jahren'],

// All different specificities
['years ago, spec year', 'en', { relative: 'year' }, '2019-12-25T12:13:14Z', '2 years ago'],
['years ago, spec month', 'en', { relative: 'month' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec week', 'en', { relative: 'week' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec day', 'en', { relative: 'day' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec hour', 'en', { relative: 'hour' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec minute', 'en', { relative: 'minute' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec second', 'en', { relative: 'second' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['months ago, spec year', 'en', { relative: 'year' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec month', 'en', { relative: 'month' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec week', 'en', { relative: 'week' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec day', 'en', { relative: 'day' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec hour', 'en', { relative: 'hour' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec minute', 'en', { relative: 'minute' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec second', 'en', { relative: 'second' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['weeks ago, spec year', 'en', { relative: 'year' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec month', 'en', { relative: 'month' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec week', 'en', { relative: 'week' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec day', 'en', { relative: 'day' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec hour', 'en', { relative: 'hour' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec minute', 'en', { relative: 'minute' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec second', 'en', { relative: 'second' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['days ago, spec year', 'en', { relative: 'year' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec month', 'en', { relative: 'month' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec week', 'en', { relative: 'week' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec day', 'en', { relative: 'day' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec hour', 'en', { relative: 'hour' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec minute', 'en', { relative: 'minute' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec second', 'en', { relative: 'second' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['hours ago, spec year', 'en', { relative: 'year' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec month', 'en', { relative: 'month' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec week', 'en', { relative: 'week' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec day', 'en', { relative: 'day' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['hours ago, spec second', 'en', { relative: 'second' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['minutes ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:11:14Z', '12/25/2021, 12:11 PM'],
['seconds ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
[
'fallback of datetime preset',
'en',
{ relative: 'second', fallback: 'datetime' },
'2021-12-20T12:13:14Z',
'12/20/2021, 12:13 PM',
],
['fallback of time preset', 'en', { relative: 'second', fallback: 'time' }, '2021-12-20T12:13:14Z', '12:13 PM'],
[
'fallback of options',
'en',
{ relative: 'second', fallback: { dateStyle: 'long', timeStyle: 'long' } },
'2021-12-20T12:13:14Z',
'December 20, 2021 at 12:13:14 PM UTC',
],
])('it can use relative format (%s)', (label, locale, options, date, expected) => {
setNavigatorLanguage(locale);
expect(new DateFormatter().format(date, options)).toBe(expected);
});
51 changes: 6 additions & 45 deletions resources/views/forms/widget.blade.php
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,6 @@
@php
use function Statamic\trans as __;
@endphp

@php
use Statamic\Support\Arr;
@endphp

<div class="card overflow-hidden p-0">
<div class="flex items-center justify-between border-b p-4 dark:border-b dark:border-dark-900 dark:bg-dark-650">
<h2>
<a class="flex items-center" href="{{ $form->showUrl() }}">
<div class="h-6 w-6 text-gray-800 dark:text-dark-200 ltr:mr-2 rtl:ml-2">
@cp_svg('icons/light/drawer-file')
</div>
<span v-pre>{{ $title }}</span>
</a>
</h2>
</div>
<div>
@if (! $submissions)
<p class="p-4 text-sm text-gray-600">{{ __('This form is awaiting responses') }}</p>
@else
<table class="data-table">
@foreach ($submissions as $submission)
<tr>
@foreach ($fields as $key => $field)
<td>
<a
href="{{ cp_route('forms.submissions.show', [$form->handle(), $submission['id']]) }}"
>
{{ Arr::get($submission, $field) }}
</a>
</td>
@endforeach

<td class="ltr:text-right rtl:text-left">
{{ $submission['date']->diffInDays() <= 14 ? $submission['date']->diffForHumans() : $submission['date']->asVueComponent() }}
</td>
</tr>
@endforeach
</table>
@endif
</div>
</div>
<form-widget
form="{{ $form->handle() }}"
title="{{ $title }}"
:fields='@json($fields)'
:initial-per-page="{{ $limit }}"
></form-widget>
1 change: 1 addition & 0 deletions src/Forms/Widget.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ public function html()
'fields' => $this->config('fields', []),
'submissions' => collect($form->submissions())->reverse()->take((int) $this->config('limit', 5))->toArray(),
'title' => $this->config('title', $form->title()),
'limit' => $this->config('limit', 5),
]);
}
}
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
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: 2 additions & 0 deletions resources/js/bootstrap/components.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ import AssetContainerList from '../components/AssetContainerList.vue';
import AddonList from '../components/AddonList.vue';
import AddonDetails from '../components/AddonDetails.vue';
import CollectionWidget from '../components/entries/CollectionWidget.vue';
import FormWidget from '../components/forms/FormWidget.vue';
import SvgIcon from '../components/SvgIcon.vue';
import FileIcon from '../components/FileIcon.vue';
import LoadingGraphic from '../components/LoadingGraphic.vue';
Expand DownExpand Up@@ -115,6 +116,7 @@ export default function registerGlobalComponents(app) {

// Widgets
app.component('collection-widget', CollectionWidget);
app.component('form-widget', FormWidget);

// Reusable
app.component('svg-icon', SvgIcon);
Expand Down
54 changes: 54 additions & 0 deletions resources/js/components/DateFormatter.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,12 +35,66 @@ export default class DateFormatter {

toString() {
try {
if (this.#options.relative) return this.#formatRelative();

return Intl.DateTimeFormat(this.locale, this.#options).format(this.#date);
} catch (e) {
return 'Invalid Date';
}
}

#formatRelative() {
let specificity = this.#options.relative;
specificity = !specificity || specificity === true ? 'year' : specificity;

const now = new Date();
const diff = now - this.#date;
const seconds = Math.abs(Math.floor(diff / 1000));
const minutes = Math.abs(Math.floor(seconds / 60));
const hours = Math.abs(Math.floor(minutes / 60));
const days = Math.abs(Math.floor(hours / 24));
const weeks = Math.abs(Math.floor(days / 7));
const months = Math.abs(Math.floor(days / 30));
const years = Math.abs(Math.floor(days / 365));

const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric: 'auto' });

// Always show seconds if less than a minute
if (seconds < 60) return rtf.format(-Math.sign(diff) * seconds, 'second');

// Show minutes if less than an hour and specificity allows
if (minutes < 60 && ['minute', 'hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * minutes, 'minute');
}

// Show hours if less than a day and specificity allows
if (hours < 24 && ['hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * hours, 'hour');
}

// Show days if less than a week and specificity allows
if (days < 7 && ['day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * days, 'day');
}

// Show weeks if less than a month and specificity allows
if (weeks < 4 && ['week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * weeks, 'week');
}

// Show months if less than a year and specificity allows
if (months < 12 && ['month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * months, 'month');
}

// Show years if specificity allows
if (specificity === 'year') {
return rtf.format(-Math.sign(diff) * years, 'year');
}

return new DateFormatter(this.#date, this.#options.fallback || 'datetime').toString();
}

static format(date, options) {
return new DateFormatter(date, options).toString();
}
Expand Down
82 changes: 82 additions & 0 deletions resources/js/components/forms/FormWidget.vue
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
<script>
import Listing from '../Listing.vue';
import DateFormatter from '@statamic/components/DateFormatter.js';
import { Widget } from '@statamic/ui';

export default {
mixins: [Listing],

components: {
Widget,
},

props: {
form: { type: String, required: true },
fields: { type: Array, default: () => [] },
title: { type: String },
},

data() {
return {
cols: [
...this.fields.map((field) => ({ label: field, field, visible: true })),
{ label: 'Date', field: 'date', visible: true },
],
listingKey: 'submissions',
requestUrl: cp_url(`forms/${this.form}/submissions`),
};
},

methods: {
formatDate(value) {
return DateFormatter.format(value, { relative: 'hour' }).toString();
},
},
};
</script>

<template>
<Widget :title="title" icon="forms">
<data-list v-if="!initializing && items.length" :rows="items" :columns="cols" :sort="false" class="w-full">
<div v-if="initializing" class="loading">
<loading-graphic />
</div>

<data-list-table
v-else
:loading="loading"
unstyled
class="[&_td]:px-0.5 [&_td]:py-0.75 [&_td]:text-sm [&_thead]:hidden"
>
<template v-for="field in fields" #[`cell-${field}`]="{ row: submission }">
<a
:href="cp_url(`forms/${form}/submissions/${submission.id}`)"
class="line-clamp-1 overflow-hidden text-ellipsis"
>
{{ submission[field] }}
</a>
</template>
<template #cell-date="{ row: submission }">
<div
class="text-end font-mono text-xs whitespace-nowrap text-gray-500 antialiased"
v-html="formatDate(submission.datestamp)"
/>
</template>
</data-list-table>
</data-list>

<p v-if="!initializing && !items.length" class="p-3 text-center text-sm text-gray-600">
{{ __('This form is awaiting responses') }}
</p>

<template #actions>
<data-list-pagination
v-if="meta.last_page != 1"
:resource-meta="meta"
@page-selected="selectPage"
:scroll-to-top="false"
:show-page-links="false"
/>
</template>
</Widget>
</template>
2 changes: 1 addition & 1 deletion resources/js/components/ui/Widget.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ defineProps({

<template>
<ui-card inset>
<div class="flex h-full flex-col justify-between">
<div class="flex h-full flex-col justify-between min-h-54">
<div>
<header class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
Expand Down
3 changes: 2 additions & 1 deletion resources/js/components/ui/index.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,5 +5,6 @@ import { default as DatePicker } from './Datepicker/Index.vue';
import { default as DateRangePicker } from './DateRangePicker/Index.vue';
import { default as TimePicker } from './TimePicker/Index.vue';
import { default as Button } from './Button/Index.vue';
import { default as Widget } from './Widget.vue';

export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button };
export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button, Widget };
103 changes: 103 additions & 0 deletions resources/js/tests/components/DateFormatter.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -208,3 +208,106 @@ test.each([
test('an invalid preset throws an error', () => {
expect(() => new DateFormatter().options('foo')).toThrow('Invalid date format: foo');
});

test.each([
// All the different diffs of time. It defaults to 'year' specificity.
['now', 'en', { relative: true }, '2021-12-25T12:13:14Z', 'now'],
['seconds ago', 'en', { relative: true }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['minute ago', 'en', { relative: true }, '2021-12-25T12:12:14Z', '1 minute ago'],
['minutes ago', 'en', { relative: true }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['hour ago', 'en', { relative: true }, '2021-12-25T11:13:14Z', '1 hour ago'],
['hours ago', 'en', { relative: true }, '2021-12-25T10:13:14Z', '2 hours ago'],
['day ago', 'en', { relative: true }, '2021-12-24T12:13:14Z', 'yesterday'],
['days ago', 'en', { relative: true }, '2021-12-23T12:13:14Z', '2 days ago'],
['week ago', 'en', { relative: true }, '2021-12-18T12:13:14Z', 'last week'],
['weeks ago', 'en', { relative: true }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['month ago', 'en', { relative: true }, '2021-11-25T12:13:14Z', 'last month'],
['months ago', 'en', { relative: true }, '2021-10-25T12:13:14Z', '2 months ago'],
['year ago', 'en', { relative: true }, '2020-12-25T12:13:14Z', 'last year'],
['years ago', 'en', { relative: true }, '2019-12-25T12:13:14Z', '2 years ago'],

// Same in another locale
['de, now', 'de', { relative: true }, '2021-12-25T12:13:14Z', 'jetzt'],
['de, seconds ago', 'de', { relative: true }, '2021-12-25T12:13:12Z', 'vor 2 Sekunden'],
['de, minute ago', 'de', { relative: true }, '2021-12-25T12:12:14Z', 'vor 1 Minute'],
['de, minutes ago', 'de', { relative: true }, '2021-12-25T12:11:14Z', 'vor 2 Minuten'],
['de, hour ago', 'de', { relative: true }, '2021-12-25T11:13:14Z', 'vor 1 Stunde'],
['de, hours ago', 'de', { relative: true }, '2021-12-25T10:13:14Z', 'vor 2 Stunden'],
['de, day ago', 'de', { relative: true }, '2021-12-24T12:13:14Z', 'gestern'],
['de, days ago', 'de', { relative: true }, '2021-12-23T12:13:14Z', 'vorgestern'],
['de, week ago', 'de', { relative: true }, '2021-12-18T12:13:14Z', 'letzte Woche'],
['de, weeks ago', 'de', { relative: true }, '2021-12-11T12:13:14Z', 'vor 2 Wochen'],
['de, month ago', 'de', { relative: true }, '2021-11-25T12:13:14Z', 'letzten Monat'],
['de, months ago', 'de', { relative: true }, '2021-10-25T12:13:14Z', 'vor 2 Monaten'],
['de, year ago', 'de', { relative: true }, '2020-12-25T12:13:14Z', 'letztes Jahr'],
['de, years ago', 'de', { relative: true }, '2019-12-25T12:13:14Z', 'vor 2 Jahren'],

// All different specificities
['years ago, spec year', 'en', { relative: 'year' }, '2019-12-25T12:13:14Z', '2 years ago'],
['years ago, spec month', 'en', { relative: 'month' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec week', 'en', { relative: 'week' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec day', 'en', { relative: 'day' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec hour', 'en', { relative: 'hour' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec minute', 'en', { relative: 'minute' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec second', 'en', { relative: 'second' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['months ago, spec year', 'en', { relative: 'year' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec month', 'en', { relative: 'month' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec week', 'en', { relative: 'week' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec day', 'en', { relative: 'day' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec hour', 'en', { relative: 'hour' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec minute', 'en', { relative: 'minute' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec second', 'en', { relative: 'second' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['weeks ago, spec year', 'en', { relative: 'year' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec month', 'en', { relative: 'month' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec week', 'en', { relative: 'week' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec day', 'en', { relative: 'day' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec hour', 'en', { relative: 'hour' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec minute', 'en', { relative: 'minute' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec second', 'en', { relative: 'second' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['days ago, spec year', 'en', { relative: 'year' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec month', 'en', { relative: 'month' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec week', 'en', { relative: 'week' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec day', 'en', { relative: 'day' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec hour', 'en', { relative: 'hour' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec minute', 'en', { relative: 'minute' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec second', 'en', { relative: 'second' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['hours ago, spec year', 'en', { relative: 'year' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec month', 'en', { relative: 'month' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec week', 'en', { relative: 'week' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec day', 'en', { relative: 'day' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['hours ago, spec second', 'en', { relative: 'second' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['minutes ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:11:14Z', '12/25/2021, 12:11 PM'],
['seconds ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
[
'fallback of datetime preset',
'en',
{ relative: 'second', fallback: 'datetime' },
'2021-12-20T12:13:14Z',
'12/20/2021, 12:13 PM',
],
['fallback of time preset', 'en', { relative: 'second', fallback: 'time' }, '2021-12-20T12:13:14Z', '12:13 PM'],
[
'fallback of options',
'en',
{ relative: 'second', fallback: { dateStyle: 'long', timeStyle: 'long' } },
'2021-12-20T12:13:14Z',
'December 20, 2021 at 12:13:14 PM UTC',
],
])('it can use relative format (%s)', (label, locale, options, date, expected) => {
setNavigatorLanguage(locale);
expect(new DateFormatter().format(date, options)).toBe(expected);
});
51 changes: 6 additions & 45 deletions resources/views/forms/widget.blade.php
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,6 @@
@php
use function Statamic\trans as __;
@endphp

@php
use Statamic\Support\Arr;
@endphp

<div class="card overflow-hidden p-0">
<div class="flex items-center justify-between border-b p-4 dark:border-b dark:border-dark-900 dark:bg-dark-650">
<h2>
<a class="flex items-center" href="{{ $form->showUrl() }}">
<div class="h-6 w-6 text-gray-800 dark:text-dark-200 ltr:mr-2 rtl:ml-2">
@cp_svg('icons/light/drawer-file')
</div>
<span v-pre>{{ $title }}</span>
</a>
</h2>
</div>
<div>
@if (! $submissions)
<p class="p-4 text-sm text-gray-600">{{ __('This form is awaiting responses') }}</p>
@else
<table class="data-table">
@foreach ($submissions as $submission)
<tr>
@foreach ($fields as $key => $field)
<td>
<a
href="{{ cp_route('forms.submissions.show', [$form->handle(), $submission['id']]) }}"
>
{{ Arr::get($submission, $field) }}
</a>
</td>
@endforeach

<td class="ltr:text-right rtl:text-left">
{{ $submission['date']->diffInDays() <= 14 ? $submission['date']->diffForHumans() : $submission['date']->asVueComponent() }}
</td>
</tr>
@endforeach
</table>
@endif
</div>
</div>
<form-widget
form="{{ $form->handle() }}"
title="{{ $title }}"
:fields='@json($fields)'
:initial-per-page="{{ $limit }}"
></form-widget>
1 change: 1 addition & 0 deletions src/Forms/Widget.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ public function html()
'fields' => $this->config('fields', []),
'submissions' => collect($form->submissions())->reverse()->take((int) $this->config('limit', 5))->toArray(),
'title' => $this->config('title', $form->title()),
'limit' => $this->config('limit', 5),
]);
}
}
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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: 2 additions & 0 deletions resources/js/bootstrap/components.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ import AssetContainerList from '../components/AssetContainerList.vue';
import AddonList from '../components/AddonList.vue';
import AddonDetails from '../components/AddonDetails.vue';
import CollectionWidget from '../components/entries/CollectionWidget.vue';
import FormWidget from '../components/forms/FormWidget.vue';
import SvgIcon from '../components/SvgIcon.vue';
import FileIcon from '../components/FileIcon.vue';
import LoadingGraphic from '../components/LoadingGraphic.vue';
Expand DownExpand Up@@ -115,6 +116,7 @@ export default function registerGlobalComponents(app) {

// Widgets
app.component('collection-widget', CollectionWidget);
app.component('form-widget', FormWidget);

// Reusable
app.component('svg-icon', SvgIcon);
Expand Down
54 changes: 54 additions & 0 deletions resources/js/components/DateFormatter.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,12 +35,66 @@ export default class DateFormatter {

toString() {
try {
if (this.#options.relative) return this.#formatRelative();

return Intl.DateTimeFormat(this.locale, this.#options).format(this.#date);
} catch (e) {
return 'Invalid Date';
}
}

#formatRelative() {
let specificity = this.#options.relative;
specificity = !specificity || specificity === true ? 'year' : specificity;

const now = new Date();
const diff = now - this.#date;
const seconds = Math.abs(Math.floor(diff / 1000));
const minutes = Math.abs(Math.floor(seconds / 60));
const hours = Math.abs(Math.floor(minutes / 60));
const days = Math.abs(Math.floor(hours / 24));
const weeks = Math.abs(Math.floor(days / 7));
const months = Math.abs(Math.floor(days / 30));
const years = Math.abs(Math.floor(days / 365));

const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric: 'auto' });

// Always show seconds if less than a minute
if (seconds < 60) return rtf.format(-Math.sign(diff) * seconds, 'second');

// Show minutes if less than an hour and specificity allows
if (minutes < 60 && ['minute', 'hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * minutes, 'minute');
}

// Show hours if less than a day and specificity allows
if (hours < 24 && ['hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * hours, 'hour');
}

// Show days if less than a week and specificity allows
if (days < 7 && ['day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * days, 'day');
}

// Show weeks if less than a month and specificity allows
if (weeks < 4 && ['week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * weeks, 'week');
}

// Show months if less than a year and specificity allows
if (months < 12 && ['month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * months, 'month');
}

// Show years if specificity allows
if (specificity === 'year') {
return rtf.format(-Math.sign(diff) * years, 'year');
}

return new DateFormatter(this.#date, this.#options.fallback || 'datetime').toString();
}

static format(date, options) {
return new DateFormatter(date, options).toString();
}
Expand Down
82 changes: 82 additions & 0 deletions resources/js/components/forms/FormWidget.vue
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
<script>
import Listing from '../Listing.vue';
import DateFormatter from '@statamic/components/DateFormatter.js';
import { Widget } from '@statamic/ui';

export default {
mixins: [Listing],

components: {
Widget,
},

props: {
form: { type: String, required: true },
fields: { type: Array, default: () => [] },
title: { type: String },
},

data() {
return {
cols: [
...this.fields.map((field) => ({ label: field, field, visible: true })),
{ label: 'Date', field: 'date', visible: true },
],
listingKey: 'submissions',
requestUrl: cp_url(`forms/${this.form}/submissions`),
};
},

methods: {
formatDate(value) {
return DateFormatter.format(value, { relative: 'hour' }).toString();
},
},
};
</script>

<template>
<Widget :title="title" icon="forms">
<data-list v-if="!initializing && items.length" :rows="items" :columns="cols" :sort="false" class="w-full">
<div v-if="initializing" class="loading">
<loading-graphic />
</div>

<data-list-table
v-else
:loading="loading"
unstyled
class="[&_td]:px-0.5 [&_td]:py-0.75 [&_td]:text-sm [&_thead]:hidden"
>
<template v-for="field in fields" #[`cell-${field}`]="{ row: submission }">
<a
:href="cp_url(`forms/${form}/submissions/${submission.id}`)"
class="line-clamp-1 overflow-hidden text-ellipsis"
>
{{ submission[field] }}
</a>
</template>
<template #cell-date="{ row: submission }">
<div
class="text-end font-mono text-xs whitespace-nowrap text-gray-500 antialiased"
v-html="formatDate(submission.datestamp)"
/>
</template>
</data-list-table>
</data-list>

<p v-if="!initializing && !items.length" class="p-3 text-center text-sm text-gray-600">
{{ __('This form is awaiting responses') }}
</p>

<template #actions>
<data-list-pagination
v-if="meta.last_page != 1"
:resource-meta="meta"
@page-selected="selectPage"
:scroll-to-top="false"
:show-page-links="false"
/>
</template>
</Widget>
</template>
2 changes: 1 addition & 1 deletion resources/js/components/ui/Widget.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ defineProps({

<template>
<ui-card inset>
<div class="flex h-full flex-col justify-between">
<div class="flex h-full flex-col justify-between min-h-54">
<div>
<header class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
Expand Down
3 changes: 2 additions & 1 deletion resources/js/components/ui/index.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,5 +5,6 @@ import { default as DatePicker } from './Datepicker/Index.vue';
import { default as DateRangePicker } from './DateRangePicker/Index.vue';
import { default as TimePicker } from './TimePicker/Index.vue';
import { default as Button } from './Button/Index.vue';
import { default as Widget } from './Widget.vue';

export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button };
export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button, Widget };
103 changes: 103 additions & 0 deletions resources/js/tests/components/DateFormatter.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -208,3 +208,106 @@ test.each([
test('an invalid preset throws an error', () => {
expect(() => new DateFormatter().options('foo')).toThrow('Invalid date format: foo');
});

test.each([
// All the different diffs of time. It defaults to 'year' specificity.
['now', 'en', { relative: true }, '2021-12-25T12:13:14Z', 'now'],
['seconds ago', 'en', { relative: true }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['minute ago', 'en', { relative: true }, '2021-12-25T12:12:14Z', '1 minute ago'],
['minutes ago', 'en', { relative: true }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['hour ago', 'en', { relative: true }, '2021-12-25T11:13:14Z', '1 hour ago'],
['hours ago', 'en', { relative: true }, '2021-12-25T10:13:14Z', '2 hours ago'],
['day ago', 'en', { relative: true }, '2021-12-24T12:13:14Z', 'yesterday'],
['days ago', 'en', { relative: true }, '2021-12-23T12:13:14Z', '2 days ago'],
['week ago', 'en', { relative: true }, '2021-12-18T12:13:14Z', 'last week'],
['weeks ago', 'en', { relative: true }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['month ago', 'en', { relative: true }, '2021-11-25T12:13:14Z', 'last month'],
['months ago', 'en', { relative: true }, '2021-10-25T12:13:14Z', '2 months ago'],
['year ago', 'en', { relative: true }, '2020-12-25T12:13:14Z', 'last year'],
['years ago', 'en', { relative: true }, '2019-12-25T12:13:14Z', '2 years ago'],

// Same in another locale
['de, now', 'de', { relative: true }, '2021-12-25T12:13:14Z', 'jetzt'],
['de, seconds ago', 'de', { relative: true }, '2021-12-25T12:13:12Z', 'vor 2 Sekunden'],
['de, minute ago', 'de', { relative: true }, '2021-12-25T12:12:14Z', 'vor 1 Minute'],
['de, minutes ago', 'de', { relative: true }, '2021-12-25T12:11:14Z', 'vor 2 Minuten'],
['de, hour ago', 'de', { relative: true }, '2021-12-25T11:13:14Z', 'vor 1 Stunde'],
['de, hours ago', 'de', { relative: true }, '2021-12-25T10:13:14Z', 'vor 2 Stunden'],
['de, day ago', 'de', { relative: true }, '2021-12-24T12:13:14Z', 'gestern'],
['de, days ago', 'de', { relative: true }, '2021-12-23T12:13:14Z', 'vorgestern'],
['de, week ago', 'de', { relative: true }, '2021-12-18T12:13:14Z', 'letzte Woche'],
['de, weeks ago', 'de', { relative: true }, '2021-12-11T12:13:14Z', 'vor 2 Wochen'],
['de, month ago', 'de', { relative: true }, '2021-11-25T12:13:14Z', 'letzten Monat'],
['de, months ago', 'de', { relative: true }, '2021-10-25T12:13:14Z', 'vor 2 Monaten'],
['de, year ago', 'de', { relative: true }, '2020-12-25T12:13:14Z', 'letztes Jahr'],
['de, years ago', 'de', { relative: true }, '2019-12-25T12:13:14Z', 'vor 2 Jahren'],

// All different specificities
['years ago, spec year', 'en', { relative: 'year' }, '2019-12-25T12:13:14Z', '2 years ago'],
['years ago, spec month', 'en', { relative: 'month' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec week', 'en', { relative: 'week' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec day', 'en', { relative: 'day' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec hour', 'en', { relative: 'hour' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec minute', 'en', { relative: 'minute' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec second', 'en', { relative: 'second' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['months ago, spec year', 'en', { relative: 'year' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec month', 'en', { relative: 'month' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec week', 'en', { relative: 'week' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec day', 'en', { relative: 'day' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec hour', 'en', { relative: 'hour' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec minute', 'en', { relative: 'minute' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec second', 'en', { relative: 'second' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['weeks ago, spec year', 'en', { relative: 'year' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec month', 'en', { relative: 'month' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec week', 'en', { relative: 'week' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec day', 'en', { relative: 'day' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec hour', 'en', { relative: 'hour' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec minute', 'en', { relative: 'minute' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec second', 'en', { relative: 'second' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['days ago, spec year', 'en', { relative: 'year' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec month', 'en', { relative: 'month' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec week', 'en', { relative: 'week' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec day', 'en', { relative: 'day' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec hour', 'en', { relative: 'hour' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec minute', 'en', { relative: 'minute' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec second', 'en', { relative: 'second' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['hours ago, spec year', 'en', { relative: 'year' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec month', 'en', { relative: 'month' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec week', 'en', { relative: 'week' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec day', 'en', { relative: 'day' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['hours ago, spec second', 'en', { relative: 'second' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['minutes ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:11:14Z', '12/25/2021, 12:11 PM'],
['seconds ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
[
'fallback of datetime preset',
'en',
{ relative: 'second', fallback: 'datetime' },
'2021-12-20T12:13:14Z',
'12/20/2021, 12:13 PM',
],
['fallback of time preset', 'en', { relative: 'second', fallback: 'time' }, '2021-12-20T12:13:14Z', '12:13 PM'],
[
'fallback of options',
'en',
{ relative: 'second', fallback: { dateStyle: 'long', timeStyle: 'long' } },
'2021-12-20T12:13:14Z',
'December 20, 2021 at 12:13:14 PM UTC',
],
])('it can use relative format (%s)', (label, locale, options, date, expected) => {
setNavigatorLanguage(locale);
expect(new DateFormatter().format(date, options)).toBe(expected);
});
51 changes: 6 additions & 45 deletions resources/views/forms/widget.blade.php
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,6 @@
@php
use function Statamic\trans as __;
@endphp

@php
use Statamic\Support\Arr;
@endphp

<div class="card overflow-hidden p-0">
<div class="flex items-center justify-between border-b p-4 dark:border-b dark:border-dark-900 dark:bg-dark-650">
<h2>
<a class="flex items-center" href="{{ $form->showUrl() }}">
<div class="h-6 w-6 text-gray-800 dark:text-dark-200 ltr:mr-2 rtl:ml-2">
@cp_svg('icons/light/drawer-file')
</div>
<span v-pre>{{ $title }}</span>
</a>
</h2>
</div>
<div>
@if (! $submissions)
<p class="p-4 text-sm text-gray-600">{{ __('This form is awaiting responses') }}</p>
@else
<table class="data-table">
@foreach ($submissions as $submission)
<tr>
@foreach ($fields as $key => $field)
<td>
<a
href="{{ cp_route('forms.submissions.show', [$form->handle(), $submission['id']]) }}"
>
{{ Arr::get($submission, $field) }}
</a>
</td>
@endforeach

<td class="ltr:text-right rtl:text-left">
{{ $submission['date']->diffInDays() <= 14 ? $submission['date']->diffForHumans() : $submission['date']->asVueComponent() }}
</td>
</tr>
@endforeach
</table>
@endif
</div>
</div>
<form-widget
form="{{ $form->handle() }}"
title="{{ $title }}"
:fields='@json($fields)'
:initial-per-page="{{ $limit }}"
></form-widget>
1 change: 1 addition & 0 deletions src/Forms/Widget.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ public function html()
'fields' => $this->config('fields', []),
'submissions' => collect($form->submissions())->reverse()->take((int) $this->config('limit', 5))->toArray(),
'title' => $this->config('title', $form->title()),
'limit' => $this->config('limit', 5),
]);
}
}
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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: 2 additions & 0 deletions resources/js/bootstrap/components.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ import AssetContainerList from '../components/AssetContainerList.vue';
import AddonList from '../components/AddonList.vue';
import AddonDetails from '../components/AddonDetails.vue';
import CollectionWidget from '../components/entries/CollectionWidget.vue';
import FormWidget from '../components/forms/FormWidget.vue';
import SvgIcon from '../components/SvgIcon.vue';
import FileIcon from '../components/FileIcon.vue';
import LoadingGraphic from '../components/LoadingGraphic.vue';
Expand DownExpand Up@@ -115,6 +116,7 @@ export default function registerGlobalComponents(app) {

// Widgets
app.component('collection-widget', CollectionWidget);
app.component('form-widget', FormWidget);

// Reusable
app.component('svg-icon', SvgIcon);
Expand Down
54 changes: 54 additions & 0 deletions resources/js/components/DateFormatter.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,12 +35,66 @@ export default class DateFormatter {

toString() {
try {
if (this.#options.relative) return this.#formatRelative();

return Intl.DateTimeFormat(this.locale, this.#options).format(this.#date);
} catch (e) {
return 'Invalid Date';
}
}

#formatRelative() {
let specificity = this.#options.relative;
specificity = !specificity || specificity === true ? 'year' : specificity;

const now = new Date();
const diff = now - this.#date;
const seconds = Math.abs(Math.floor(diff / 1000));
const minutes = Math.abs(Math.floor(seconds / 60));
const hours = Math.abs(Math.floor(minutes / 60));
const days = Math.abs(Math.floor(hours / 24));
const weeks = Math.abs(Math.floor(days / 7));
const months = Math.abs(Math.floor(days / 30));
const years = Math.abs(Math.floor(days / 365));

const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric: 'auto' });

// Always show seconds if less than a minute
if (seconds < 60) return rtf.format(-Math.sign(diff) * seconds, 'second');

// Show minutes if less than an hour and specificity allows
if (minutes < 60 && ['minute', 'hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * minutes, 'minute');
}

// Show hours if less than a day and specificity allows
if (hours < 24 && ['hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * hours, 'hour');
}

// Show days if less than a week and specificity allows
if (days < 7 && ['day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * days, 'day');
}

// Show weeks if less than a month and specificity allows
if (weeks < 4 && ['week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * weeks, 'week');
}

// Show months if less than a year and specificity allows
if (months < 12 && ['month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * months, 'month');
}

// Show years if specificity allows
if (specificity === 'year') {
return rtf.format(-Math.sign(diff) * years, 'year');
}

return new DateFormatter(this.#date, this.#options.fallback || 'datetime').toString();
}

static format(date, options) {
return new DateFormatter(date, options).toString();
}
Expand Down
82 changes: 82 additions & 0 deletions resources/js/components/forms/FormWidget.vue
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
<script>
import Listing from '../Listing.vue';
import DateFormatter from '@statamic/components/DateFormatter.js';
import { Widget } from '@statamic/ui';

export default {
mixins: [Listing],

components: {
Widget,
},

props: {
form: { type: String, required: true },
fields: { type: Array, default: () => [] },
title: { type: String },
},

data() {
return {
cols: [
...this.fields.map((field) => ({ label: field, field, visible: true })),
{ label: 'Date', field: 'date', visible: true },
],
listingKey: 'submissions',
requestUrl: cp_url(`forms/${this.form}/submissions`),
};
},

methods: {
formatDate(value) {
return DateFormatter.format(value, { relative: 'hour' }).toString();
},
},
};
</script>

<template>
<Widget :title="title" icon="forms">
<data-list v-if="!initializing && items.length" :rows="items" :columns="cols" :sort="false" class="w-full">
<div v-if="initializing" class="loading">
<loading-graphic />
</div>

<data-list-table
v-else
:loading="loading"
unstyled
class="[&_td]:px-0.5 [&_td]:py-0.75 [&_td]:text-sm [&_thead]:hidden"
>
<template v-for="field in fields" #[`cell-${field}`]="{ row: submission }">
<a
:href="cp_url(`forms/${form}/submissions/${submission.id}`)"
class="line-clamp-1 overflow-hidden text-ellipsis"
>
{{ submission[field] }}
</a>
</template>
<template #cell-date="{ row: submission }">
<div
class="text-end font-mono text-xs whitespace-nowrap text-gray-500 antialiased"
v-html="formatDate(submission.datestamp)"
/>
</template>
</data-list-table>
</data-list>

<p v-if="!initializing && !items.length" class="p-3 text-center text-sm text-gray-600">
{{ __('This form is awaiting responses') }}
</p>

<template #actions>
<data-list-pagination
v-if="meta.last_page != 1"
:resource-meta="meta"
@page-selected="selectPage"
:scroll-to-top="false"
:show-page-links="false"
/>
</template>
</Widget>
</template>
2 changes: 1 addition & 1 deletion resources/js/components/ui/Widget.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ defineProps({

<template>
<ui-card inset>
<div class="flex h-full flex-col justify-between">
<div class="flex h-full flex-col justify-between min-h-54">
<div>
<header class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
Expand Down
3 changes: 2 additions & 1 deletion resources/js/components/ui/index.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,5 +5,6 @@ import { default as DatePicker } from './Datepicker/Index.vue';
import { default as DateRangePicker } from './DateRangePicker/Index.vue';
import { default as TimePicker } from './TimePicker/Index.vue';
import { default as Button } from './Button/Index.vue';
import { default as Widget } from './Widget.vue';

export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button };
export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button, Widget };
103 changes: 103 additions & 0 deletions resources/js/tests/components/DateFormatter.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -208,3 +208,106 @@ test.each([
test('an invalid preset throws an error', () => {
expect(() => new DateFormatter().options('foo')).toThrow('Invalid date format: foo');
});

test.each([
// All the different diffs of time. It defaults to 'year' specificity.
['now', 'en', { relative: true }, '2021-12-25T12:13:14Z', 'now'],
['seconds ago', 'en', { relative: true }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['minute ago', 'en', { relative: true }, '2021-12-25T12:12:14Z', '1 minute ago'],
['minutes ago', 'en', { relative: true }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['hour ago', 'en', { relative: true }, '2021-12-25T11:13:14Z', '1 hour ago'],
['hours ago', 'en', { relative: true }, '2021-12-25T10:13:14Z', '2 hours ago'],
['day ago', 'en', { relative: true }, '2021-12-24T12:13:14Z', 'yesterday'],
['days ago', 'en', { relative: true }, '2021-12-23T12:13:14Z', '2 days ago'],
['week ago', 'en', { relative: true }, '2021-12-18T12:13:14Z', 'last week'],
['weeks ago', 'en', { relative: true }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['month ago', 'en', { relative: true }, '2021-11-25T12:13:14Z', 'last month'],
['months ago', 'en', { relative: true }, '2021-10-25T12:13:14Z', '2 months ago'],
['year ago', 'en', { relative: true }, '2020-12-25T12:13:14Z', 'last year'],
['years ago', 'en', { relative: true }, '2019-12-25T12:13:14Z', '2 years ago'],

// Same in another locale
['de, now', 'de', { relative: true }, '2021-12-25T12:13:14Z', 'jetzt'],
['de, seconds ago', 'de', { relative: true }, '2021-12-25T12:13:12Z', 'vor 2 Sekunden'],
['de, minute ago', 'de', { relative: true }, '2021-12-25T12:12:14Z', 'vor 1 Minute'],
['de, minutes ago', 'de', { relative: true }, '2021-12-25T12:11:14Z', 'vor 2 Minuten'],
['de, hour ago', 'de', { relative: true }, '2021-12-25T11:13:14Z', 'vor 1 Stunde'],
['de, hours ago', 'de', { relative: true }, '2021-12-25T10:13:14Z', 'vor 2 Stunden'],
['de, day ago', 'de', { relative: true }, '2021-12-24T12:13:14Z', 'gestern'],
['de, days ago', 'de', { relative: true }, '2021-12-23T12:13:14Z', 'vorgestern'],
['de, week ago', 'de', { relative: true }, '2021-12-18T12:13:14Z', 'letzte Woche'],
['de, weeks ago', 'de', { relative: true }, '2021-12-11T12:13:14Z', 'vor 2 Wochen'],
['de, month ago', 'de', { relative: true }, '2021-11-25T12:13:14Z', 'letzten Monat'],
['de, months ago', 'de', { relative: true }, '2021-10-25T12:13:14Z', 'vor 2 Monaten'],
['de, year ago', 'de', { relative: true }, '2020-12-25T12:13:14Z', 'letztes Jahr'],
['de, years ago', 'de', { relative: true }, '2019-12-25T12:13:14Z', 'vor 2 Jahren'],

// All different specificities
['years ago, spec year', 'en', { relative: 'year' }, '2019-12-25T12:13:14Z', '2 years ago'],
['years ago, spec month', 'en', { relative: 'month' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec week', 'en', { relative: 'week' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec day', 'en', { relative: 'day' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec hour', 'en', { relative: 'hour' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec minute', 'en', { relative: 'minute' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec second', 'en', { relative: 'second' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['months ago, spec year', 'en', { relative: 'year' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec month', 'en', { relative: 'month' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec week', 'en', { relative: 'week' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec day', 'en', { relative: 'day' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec hour', 'en', { relative: 'hour' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec minute', 'en', { relative: 'minute' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec second', 'en', { relative: 'second' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['weeks ago, spec year', 'en', { relative: 'year' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec month', 'en', { relative: 'month' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec week', 'en', { relative: 'week' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec day', 'en', { relative: 'day' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec hour', 'en', { relative: 'hour' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec minute', 'en', { relative: 'minute' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec second', 'en', { relative: 'second' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['days ago, spec year', 'en', { relative: 'year' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec month', 'en', { relative: 'month' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec week', 'en', { relative: 'week' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec day', 'en', { relative: 'day' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec hour', 'en', { relative: 'hour' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec minute', 'en', { relative: 'minute' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec second', 'en', { relative: 'second' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['hours ago, spec year', 'en', { relative: 'year' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec month', 'en', { relative: 'month' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec week', 'en', { relative: 'week' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec day', 'en', { relative: 'day' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['hours ago, spec second', 'en', { relative: 'second' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['minutes ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:11:14Z', '12/25/2021, 12:11 PM'],
['seconds ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
[
'fallback of datetime preset',
'en',
{ relative: 'second', fallback: 'datetime' },
'2021-12-20T12:13:14Z',
'12/20/2021, 12:13 PM',
],
['fallback of time preset', 'en', { relative: 'second', fallback: 'time' }, '2021-12-20T12:13:14Z', '12:13 PM'],
[
'fallback of options',
'en',
{ relative: 'second', fallback: { dateStyle: 'long', timeStyle: 'long' } },
'2021-12-20T12:13:14Z',
'December 20, 2021 at 12:13:14 PM UTC',
],
])('it can use relative format (%s)', (label, locale, options, date, expected) => {
setNavigatorLanguage(locale);
expect(new DateFormatter().format(date, options)).toBe(expected);
});
51 changes: 6 additions & 45 deletions resources/views/forms/widget.blade.php
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,6 @@
@php
use function Statamic\trans as __;
@endphp

@php
use Statamic\Support\Arr;
@endphp

<div class="card overflow-hidden p-0">
<div class="flex items-center justify-between border-b p-4 dark:border-b dark:border-dark-900 dark:bg-dark-650">
<h2>
<a class="flex items-center" href="{{ $form->showUrl() }}">
<div class="h-6 w-6 text-gray-800 dark:text-dark-200 ltr:mr-2 rtl:ml-2">
@cp_svg('icons/light/drawer-file')
</div>
<span v-pre>{{ $title }}</span>
</a>
</h2>
</div>
<div>
@if (! $submissions)
<p class="p-4 text-sm text-gray-600">{{ __('This form is awaiting responses') }}</p>
@else
<table class="data-table">
@foreach ($submissions as $submission)
<tr>
@foreach ($fields as $key => $field)
<td>
<a
href="{{ cp_route('forms.submissions.show', [$form->handle(), $submission['id']]) }}"
>
{{ Arr::get($submission, $field) }}
</a>
</td>
@endforeach

<td class="ltr:text-right rtl:text-left">
{{ $submission['date']->diffInDays() <= 14 ? $submission['date']->diffForHumans() : $submission['date']->asVueComponent() }}
</td>
</tr>
@endforeach
</table>
@endif
</div>
</div>
<form-widget
form="{{ $form->handle() }}"
title="{{ $title }}"
:fields='@json($fields)'
:initial-per-page="{{ $limit }}"
></form-widget>
1 change: 1 addition & 0 deletions src/Forms/Widget.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ public function html()
'fields' => $this->config('fields', []),
'submissions' => collect($form->submissions())->reverse()->take((int) $this->config('limit', 5))->toArray(),
'title' => $this->config('title', $form->title()),
'limit' => $this->config('limit', 5),
]);
}
}
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
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: 2 additions & 0 deletions resources/js/bootstrap/components.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ import AssetContainerList from '../components/AssetContainerList.vue';
import AddonList from '../components/AddonList.vue';
import AddonDetails from '../components/AddonDetails.vue';
import CollectionWidget from '../components/entries/CollectionWidget.vue';
import FormWidget from '../components/forms/FormWidget.vue';
import SvgIcon from '../components/SvgIcon.vue';
import FileIcon from '../components/FileIcon.vue';
import LoadingGraphic from '../components/LoadingGraphic.vue';
Expand DownExpand Up@@ -115,6 +116,7 @@ export default function registerGlobalComponents(app) {

// Widgets
app.component('collection-widget', CollectionWidget);
app.component('form-widget', FormWidget);

// Reusable
app.component('svg-icon', SvgIcon);
Expand Down
54 changes: 54 additions & 0 deletions resources/js/components/DateFormatter.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,12 +35,66 @@ export default class DateFormatter {

toString() {
try {
if (this.#options.relative) return this.#formatRelative();

return Intl.DateTimeFormat(this.locale, this.#options).format(this.#date);
} catch (e) {
return 'Invalid Date';
}
}

#formatRelative() {
let specificity = this.#options.relative;
specificity = !specificity || specificity === true ? 'year' : specificity;

const now = new Date();
const diff = now - this.#date;
const seconds = Math.abs(Math.floor(diff / 1000));
const minutes = Math.abs(Math.floor(seconds / 60));
const hours = Math.abs(Math.floor(minutes / 60));
const days = Math.abs(Math.floor(hours / 24));
const weeks = Math.abs(Math.floor(days / 7));
const months = Math.abs(Math.floor(days / 30));
const years = Math.abs(Math.floor(days / 365));

const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric: 'auto' });

// Always show seconds if less than a minute
if (seconds < 60) return rtf.format(-Math.sign(diff) * seconds, 'second');

// Show minutes if less than an hour and specificity allows
if (minutes < 60 && ['minute', 'hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * minutes, 'minute');
}

// Show hours if less than a day and specificity allows
if (hours < 24 && ['hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * hours, 'hour');
}

// Show days if less than a week and specificity allows
if (days < 7 && ['day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * days, 'day');
}

// Show weeks if less than a month and specificity allows
if (weeks < 4 && ['week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * weeks, 'week');
}

// Show months if less than a year and specificity allows
if (months < 12 && ['month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * months, 'month');
}

// Show years if specificity allows
if (specificity === 'year') {
return rtf.format(-Math.sign(diff) * years, 'year');
}

return new DateFormatter(this.#date, this.#options.fallback || 'datetime').toString();
}

static format(date, options) {
return new DateFormatter(date, options).toString();
}
Expand Down
82 changes: 82 additions & 0 deletions resources/js/components/forms/FormWidget.vue
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
<script>
import Listing from '../Listing.vue';
import DateFormatter from '@statamic/components/DateFormatter.js';
import { Widget } from '@statamic/ui';

export default {
mixins: [Listing],

components: {
Widget,
},

props: {
form: { type: String, required: true },
fields: { type: Array, default: () => [] },
title: { type: String },
},

data() {
return {
cols: [
...this.fields.map((field) => ({ label: field, field, visible: true })),
{ label: 'Date', field: 'date', visible: true },
],
listingKey: 'submissions',
requestUrl: cp_url(`forms/${this.form}/submissions`),
};
},

methods: {
formatDate(value) {
return DateFormatter.format(value, { relative: 'hour' }).toString();
},
},
};
</script>

<template>
<Widget :title="title" icon="forms">
<data-list v-if="!initializing && items.length" :rows="items" :columns="cols" :sort="false" class="w-full">
<div v-if="initializing" class="loading">
<loading-graphic />
</div>

<data-list-table
v-else
:loading="loading"
unstyled
class="[&_td]:px-0.5 [&_td]:py-0.75 [&_td]:text-sm [&_thead]:hidden"
>
<template v-for="field in fields" #[`cell-${field}`]="{ row: submission }">
<a
:href="cp_url(`forms/${form}/submissions/${submission.id}`)"
class="line-clamp-1 overflow-hidden text-ellipsis"
>
{{ submission[field] }}
</a>
</template>
<template #cell-date="{ row: submission }">
<div
class="text-end font-mono text-xs whitespace-nowrap text-gray-500 antialiased"
v-html="formatDate(submission.datestamp)"
/>
</template>
</data-list-table>
</data-list>

<p v-if="!initializing && !items.length" class="p-3 text-center text-sm text-gray-600">
{{ __('This form is awaiting responses') }}
</p>

<template #actions>
<data-list-pagination
v-if="meta.last_page != 1"
:resource-meta="meta"
@page-selected="selectPage"
:scroll-to-top="false"
:show-page-links="false"
/>
</template>
</Widget>
</template>
2 changes: 1 addition & 1 deletion resources/js/components/ui/Widget.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ defineProps({

<template>
<ui-card inset>
<div class="flex h-full flex-col justify-between">
<div class="flex h-full flex-col justify-between min-h-54">
<div>
<header class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
Expand Down
3 changes: 2 additions & 1 deletion resources/js/components/ui/index.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,5 +5,6 @@ import { default as DatePicker } from './Datepicker/Index.vue';
import { default as DateRangePicker } from './DateRangePicker/Index.vue';
import { default as TimePicker } from './TimePicker/Index.vue';
import { default as Button } from './Button/Index.vue';
import { default as Widget } from './Widget.vue';

export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button };
export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button, Widget };
103 changes: 103 additions & 0 deletions resources/js/tests/components/DateFormatter.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -208,3 +208,106 @@ test.each([
test('an invalid preset throws an error', () => {
expect(() => new DateFormatter().options('foo')).toThrow('Invalid date format: foo');
});

test.each([
// All the different diffs of time. It defaults to 'year' specificity.
['now', 'en', { relative: true }, '2021-12-25T12:13:14Z', 'now'],
['seconds ago', 'en', { relative: true }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['minute ago', 'en', { relative: true }, '2021-12-25T12:12:14Z', '1 minute ago'],
['minutes ago', 'en', { relative: true }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['hour ago', 'en', { relative: true }, '2021-12-25T11:13:14Z', '1 hour ago'],
['hours ago', 'en', { relative: true }, '2021-12-25T10:13:14Z', '2 hours ago'],
['day ago', 'en', { relative: true }, '2021-12-24T12:13:14Z', 'yesterday'],
['days ago', 'en', { relative: true }, '2021-12-23T12:13:14Z', '2 days ago'],
['week ago', 'en', { relative: true }, '2021-12-18T12:13:14Z', 'last week'],
['weeks ago', 'en', { relative: true }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['month ago', 'en', { relative: true }, '2021-11-25T12:13:14Z', 'last month'],
['months ago', 'en', { relative: true }, '2021-10-25T12:13:14Z', '2 months ago'],
['year ago', 'en', { relative: true }, '2020-12-25T12:13:14Z', 'last year'],
['years ago', 'en', { relative: true }, '2019-12-25T12:13:14Z', '2 years ago'],

// Same in another locale
['de, now', 'de', { relative: true }, '2021-12-25T12:13:14Z', 'jetzt'],
['de, seconds ago', 'de', { relative: true }, '2021-12-25T12:13:12Z', 'vor 2 Sekunden'],
['de, minute ago', 'de', { relative: true }, '2021-12-25T12:12:14Z', 'vor 1 Minute'],
['de, minutes ago', 'de', { relative: true }, '2021-12-25T12:11:14Z', 'vor 2 Minuten'],
['de, hour ago', 'de', { relative: true }, '2021-12-25T11:13:14Z', 'vor 1 Stunde'],
['de, hours ago', 'de', { relative: true }, '2021-12-25T10:13:14Z', 'vor 2 Stunden'],
['de, day ago', 'de', { relative: true }, '2021-12-24T12:13:14Z', 'gestern'],
['de, days ago', 'de', { relative: true }, '2021-12-23T12:13:14Z', 'vorgestern'],
['de, week ago', 'de', { relative: true }, '2021-12-18T12:13:14Z', 'letzte Woche'],
['de, weeks ago', 'de', { relative: true }, '2021-12-11T12:13:14Z', 'vor 2 Wochen'],
['de, month ago', 'de', { relative: true }, '2021-11-25T12:13:14Z', 'letzten Monat'],
['de, months ago', 'de', { relative: true }, '2021-10-25T12:13:14Z', 'vor 2 Monaten'],
['de, year ago', 'de', { relative: true }, '2020-12-25T12:13:14Z', 'letztes Jahr'],
['de, years ago', 'de', { relative: true }, '2019-12-25T12:13:14Z', 'vor 2 Jahren'],

// All different specificities
['years ago, spec year', 'en', { relative: 'year' }, '2019-12-25T12:13:14Z', '2 years ago'],
['years ago, spec month', 'en', { relative: 'month' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec week', 'en', { relative: 'week' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec day', 'en', { relative: 'day' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec hour', 'en', { relative: 'hour' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec minute', 'en', { relative: 'minute' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec second', 'en', { relative: 'second' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['months ago, spec year', 'en', { relative: 'year' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec month', 'en', { relative: 'month' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec week', 'en', { relative: 'week' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec day', 'en', { relative: 'day' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec hour', 'en', { relative: 'hour' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec minute', 'en', { relative: 'minute' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec second', 'en', { relative: 'second' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['weeks ago, spec year', 'en', { relative: 'year' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec month', 'en', { relative: 'month' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec week', 'en', { relative: 'week' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec day', 'en', { relative: 'day' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec hour', 'en', { relative: 'hour' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec minute', 'en', { relative: 'minute' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec second', 'en', { relative: 'second' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['days ago, spec year', 'en', { relative: 'year' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec month', 'en', { relative: 'month' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec week', 'en', { relative: 'week' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec day', 'en', { relative: 'day' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec hour', 'en', { relative: 'hour' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec minute', 'en', { relative: 'minute' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec second', 'en', { relative: 'second' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['hours ago, spec year', 'en', { relative: 'year' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec month', 'en', { relative: 'month' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec week', 'en', { relative: 'week' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec day', 'en', { relative: 'day' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['hours ago, spec second', 'en', { relative: 'second' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['minutes ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:11:14Z', '12/25/2021, 12:11 PM'],
['seconds ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
[
'fallback of datetime preset',
'en',
{ relative: 'second', fallback: 'datetime' },
'2021-12-20T12:13:14Z',
'12/20/2021, 12:13 PM',
],
['fallback of time preset', 'en', { relative: 'second', fallback: 'time' }, '2021-12-20T12:13:14Z', '12:13 PM'],
[
'fallback of options',
'en',
{ relative: 'second', fallback: { dateStyle: 'long', timeStyle: 'long' } },
'2021-12-20T12:13:14Z',
'December 20, 2021 at 12:13:14 PM UTC',
],
])('it can use relative format (%s)', (label, locale, options, date, expected) => {
setNavigatorLanguage(locale);
expect(new DateFormatter().format(date, options)).toBe(expected);
});
51 changes: 6 additions & 45 deletions resources/views/forms/widget.blade.php
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,6 @@
@php
use function Statamic\trans as __;
@endphp

@php
use Statamic\Support\Arr;
@endphp

<div class="card overflow-hidden p-0">
<div class="flex items-center justify-between border-b p-4 dark:border-b dark:border-dark-900 dark:bg-dark-650">
<h2>
<a class="flex items-center" href="{{ $form->showUrl() }}">
<div class="h-6 w-6 text-gray-800 dark:text-dark-200 ltr:mr-2 rtl:ml-2">
@cp_svg('icons/light/drawer-file')
</div>
<span v-pre>{{ $title }}</span>
</a>
</h2>
</div>
<div>
@if (! $submissions)
<p class="p-4 text-sm text-gray-600">{{ __('This form is awaiting responses') }}</p>
@else
<table class="data-table">
@foreach ($submissions as $submission)
<tr>
@foreach ($fields as $key => $field)
<td>
<a
href="{{ cp_route('forms.submissions.show', [$form->handle(), $submission['id']]) }}"
>
{{ Arr::get($submission, $field) }}
</a>
</td>
@endforeach

<td class="ltr:text-right rtl:text-left">
{{ $submission['date']->diffInDays() <= 14 ? $submission['date']->diffForHumans() : $submission['date']->asVueComponent() }}
</td>
</tr>
@endforeach
</table>
@endif
</div>
</div>
<form-widget
form="{{ $form->handle() }}"
title="{{ $title }}"
:fields='@json($fields)'
:initial-per-page="{{ $limit }}"
></form-widget>
1 change: 1 addition & 0 deletions src/Forms/Widget.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ public function html()
'fields' => $this->config('fields', []),
'submissions' => collect($form->submissions())->reverse()->take((int) $this->config('limit', 5))->toArray(),
'title' => $this->config('title', $form->title()),
'limit' => $this->config('limit', 5),
]);
}
}
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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: 2 additions & 0 deletions resources/js/bootstrap/components.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ import AssetContainerList from '../components/AssetContainerList.vue';
import AddonList from '../components/AddonList.vue';
import AddonDetails from '../components/AddonDetails.vue';
import CollectionWidget from '../components/entries/CollectionWidget.vue';
import FormWidget from '../components/forms/FormWidget.vue';
import SvgIcon from '../components/SvgIcon.vue';
import FileIcon from '../components/FileIcon.vue';
import LoadingGraphic from '../components/LoadingGraphic.vue';
Expand DownExpand Up@@ -115,6 +116,7 @@ export default function registerGlobalComponents(app) {

// Widgets
app.component('collection-widget', CollectionWidget);
app.component('form-widget', FormWidget);

// Reusable
app.component('svg-icon', SvgIcon);
Expand Down
54 changes: 54 additions & 0 deletions resources/js/components/DateFormatter.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,12 +35,66 @@ export default class DateFormatter {

toString() {
try {
if (this.#options.relative) return this.#formatRelative();

return Intl.DateTimeFormat(this.locale, this.#options).format(this.#date);
} catch (e) {
return 'Invalid Date';
}
}

#formatRelative() {
let specificity = this.#options.relative;
specificity = !specificity || specificity === true ? 'year' : specificity;

const now = new Date();
const diff = now - this.#date;
const seconds = Math.abs(Math.floor(diff / 1000));
const minutes = Math.abs(Math.floor(seconds / 60));
const hours = Math.abs(Math.floor(minutes / 60));
const days = Math.abs(Math.floor(hours / 24));
const weeks = Math.abs(Math.floor(days / 7));
const months = Math.abs(Math.floor(days / 30));
const years = Math.abs(Math.floor(days / 365));

const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric: 'auto' });

// Always show seconds if less than a minute
if (seconds < 60) return rtf.format(-Math.sign(diff) * seconds, 'second');

// Show minutes if less than an hour and specificity allows
if (minutes < 60 && ['minute', 'hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * minutes, 'minute');
}

// Show hours if less than a day and specificity allows
if (hours < 24 && ['hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * hours, 'hour');
}

// Show days if less than a week and specificity allows
if (days < 7 && ['day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * days, 'day');
}

// Show weeks if less than a month and specificity allows
if (weeks < 4 && ['week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * weeks, 'week');
}

// Show months if less than a year and specificity allows
if (months < 12 && ['month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * months, 'month');
}

// Show years if specificity allows
if (specificity === 'year') {
return rtf.format(-Math.sign(diff) * years, 'year');
}

return new DateFormatter(this.#date, this.#options.fallback || 'datetime').toString();
}

static format(date, options) {
return new DateFormatter(date, options).toString();
}
Expand Down
82 changes: 82 additions & 0 deletions resources/js/components/forms/FormWidget.vue
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
<script>
import Listing from '../Listing.vue';
import DateFormatter from '@statamic/components/DateFormatter.js';
import { Widget } from '@statamic/ui';

export default {
mixins: [Listing],

components: {
Widget,
},

props: {
form: { type: String, required: true },
fields: { type: Array, default: () => [] },
title: { type: String },
},

data() {
return {
cols: [
...this.fields.map((field) => ({ label: field, field, visible: true })),
{ label: 'Date', field: 'date', visible: true },
],
listingKey: 'submissions',
requestUrl: cp_url(`forms/${this.form}/submissions`),
};
},

methods: {
formatDate(value) {
return DateFormatter.format(value, { relative: 'hour' }).toString();
},
},
};
</script>

<template>
<Widget :title="title" icon="forms">
<data-list v-if="!initializing && items.length" :rows="items" :columns="cols" :sort="false" class="w-full">
<div v-if="initializing" class="loading">
<loading-graphic />
</div>

<data-list-table
v-else
:loading="loading"
unstyled
class="[&_td]:px-0.5 [&_td]:py-0.75 [&_td]:text-sm [&_thead]:hidden"
>
<template v-for="field in fields" #[`cell-${field}`]="{ row: submission }">
<a
:href="cp_url(`forms/${form}/submissions/${submission.id}`)"
class="line-clamp-1 overflow-hidden text-ellipsis"
>
{{ submission[field] }}
</a>
</template>
<template #cell-date="{ row: submission }">
<div
class="text-end font-mono text-xs whitespace-nowrap text-gray-500 antialiased"
v-html="formatDate(submission.datestamp)"
/>
</template>
</data-list-table>
</data-list>

<p v-if="!initializing && !items.length" class="p-3 text-center text-sm text-gray-600">
{{ __('This form is awaiting responses') }}
</p>

<template #actions>
<data-list-pagination
v-if="meta.last_page != 1"
:resource-meta="meta"
@page-selected="selectPage"
:scroll-to-top="false"
:show-page-links="false"
/>
</template>
</Widget>
</template>
2 changes: 1 addition & 1 deletion resources/js/components/ui/Widget.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ defineProps({

<template>
<ui-card inset>
<div class="flex h-full flex-col justify-between">
<div class="flex h-full flex-col justify-between min-h-54">
<div>
<header class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
Expand Down
3 changes: 2 additions & 1 deletion resources/js/components/ui/index.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,5 +5,6 @@ import { default as DatePicker } from './Datepicker/Index.vue';
import { default as DateRangePicker } from './DateRangePicker/Index.vue';
import { default as TimePicker } from './TimePicker/Index.vue';
import { default as Button } from './Button/Index.vue';
import { default as Widget } from './Widget.vue';

export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button };
export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button, Widget };
103 changes: 103 additions & 0 deletions resources/js/tests/components/DateFormatter.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -208,3 +208,106 @@ test.each([
test('an invalid preset throws an error', () => {
expect(() => new DateFormatter().options('foo')).toThrow('Invalid date format: foo');
});

test.each([
// All the different diffs of time. It defaults to 'year' specificity.
['now', 'en', { relative: true }, '2021-12-25T12:13:14Z', 'now'],
['seconds ago', 'en', { relative: true }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['minute ago', 'en', { relative: true }, '2021-12-25T12:12:14Z', '1 minute ago'],
['minutes ago', 'en', { relative: true }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['hour ago', 'en', { relative: true }, '2021-12-25T11:13:14Z', '1 hour ago'],
['hours ago', 'en', { relative: true }, '2021-12-25T10:13:14Z', '2 hours ago'],
['day ago', 'en', { relative: true }, '2021-12-24T12:13:14Z', 'yesterday'],
['days ago', 'en', { relative: true }, '2021-12-23T12:13:14Z', '2 days ago'],
['week ago', 'en', { relative: true }, '2021-12-18T12:13:14Z', 'last week'],
['weeks ago', 'en', { relative: true }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['month ago', 'en', { relative: true }, '2021-11-25T12:13:14Z', 'last month'],
['months ago', 'en', { relative: true }, '2021-10-25T12:13:14Z', '2 months ago'],
['year ago', 'en', { relative: true }, '2020-12-25T12:13:14Z', 'last year'],
['years ago', 'en', { relative: true }, '2019-12-25T12:13:14Z', '2 years ago'],

// Same in another locale
['de, now', 'de', { relative: true }, '2021-12-25T12:13:14Z', 'jetzt'],
['de, seconds ago', 'de', { relative: true }, '2021-12-25T12:13:12Z', 'vor 2 Sekunden'],
['de, minute ago', 'de', { relative: true }, '2021-12-25T12:12:14Z', 'vor 1 Minute'],
['de, minutes ago', 'de', { relative: true }, '2021-12-25T12:11:14Z', 'vor 2 Minuten'],
['de, hour ago', 'de', { relative: true }, '2021-12-25T11:13:14Z', 'vor 1 Stunde'],
['de, hours ago', 'de', { relative: true }, '2021-12-25T10:13:14Z', 'vor 2 Stunden'],
['de, day ago', 'de', { relative: true }, '2021-12-24T12:13:14Z', 'gestern'],
['de, days ago', 'de', { relative: true }, '2021-12-23T12:13:14Z', 'vorgestern'],
['de, week ago', 'de', { relative: true }, '2021-12-18T12:13:14Z', 'letzte Woche'],
['de, weeks ago', 'de', { relative: true }, '2021-12-11T12:13:14Z', 'vor 2 Wochen'],
['de, month ago', 'de', { relative: true }, '2021-11-25T12:13:14Z', 'letzten Monat'],
['de, months ago', 'de', { relative: true }, '2021-10-25T12:13:14Z', 'vor 2 Monaten'],
['de, year ago', 'de', { relative: true }, '2020-12-25T12:13:14Z', 'letztes Jahr'],
['de, years ago', 'de', { relative: true }, '2019-12-25T12:13:14Z', 'vor 2 Jahren'],

// All different specificities
['years ago, spec year', 'en', { relative: 'year' }, '2019-12-25T12:13:14Z', '2 years ago'],
['years ago, spec month', 'en', { relative: 'month' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec week', 'en', { relative: 'week' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec day', 'en', { relative: 'day' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec hour', 'en', { relative: 'hour' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec minute', 'en', { relative: 'minute' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec second', 'en', { relative: 'second' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['months ago, spec year', 'en', { relative: 'year' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec month', 'en', { relative: 'month' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec week', 'en', { relative: 'week' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec day', 'en', { relative: 'day' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec hour', 'en', { relative: 'hour' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec minute', 'en', { relative: 'minute' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec second', 'en', { relative: 'second' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['weeks ago, spec year', 'en', { relative: 'year' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec month', 'en', { relative: 'month' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec week', 'en', { relative: 'week' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec day', 'en', { relative: 'day' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec hour', 'en', { relative: 'hour' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec minute', 'en', { relative: 'minute' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec second', 'en', { relative: 'second' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['days ago, spec year', 'en', { relative: 'year' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec month', 'en', { relative: 'month' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec week', 'en', { relative: 'week' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec day', 'en', { relative: 'day' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec hour', 'en', { relative: 'hour' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec minute', 'en', { relative: 'minute' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec second', 'en', { relative: 'second' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['hours ago, spec year', 'en', { relative: 'year' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec month', 'en', { relative: 'month' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec week', 'en', { relative: 'week' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec day', 'en', { relative: 'day' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['hours ago, spec second', 'en', { relative: 'second' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['minutes ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:11:14Z', '12/25/2021, 12:11 PM'],
['seconds ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
[
'fallback of datetime preset',
'en',
{ relative: 'second', fallback: 'datetime' },
'2021-12-20T12:13:14Z',
'12/20/2021, 12:13 PM',
],
['fallback of time preset', 'en', { relative: 'second', fallback: 'time' }, '2021-12-20T12:13:14Z', '12:13 PM'],
[
'fallback of options',
'en',
{ relative: 'second', fallback: { dateStyle: 'long', timeStyle: 'long' } },
'2021-12-20T12:13:14Z',
'December 20, 2021 at 12:13:14 PM UTC',
],
])('it can use relative format (%s)', (label, locale, options, date, expected) => {
setNavigatorLanguage(locale);
expect(new DateFormatter().format(date, options)).toBe(expected);
});
51 changes: 6 additions & 45 deletions resources/views/forms/widget.blade.php
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,6 @@
@php
use function Statamic\trans as __;
@endphp

@php
use Statamic\Support\Arr;
@endphp

<div class="card overflow-hidden p-0">
<div class="flex items-center justify-between border-b p-4 dark:border-b dark:border-dark-900 dark:bg-dark-650">
<h2>
<a class="flex items-center" href="{{ $form->showUrl() }}">
<div class="h-6 w-6 text-gray-800 dark:text-dark-200 ltr:mr-2 rtl:ml-2">
@cp_svg('icons/light/drawer-file')
</div>
<span v-pre>{{ $title }}</span>
</a>
</h2>
</div>
<div>
@if (! $submissions)
<p class="p-4 text-sm text-gray-600">{{ __('This form is awaiting responses') }}</p>
@else
<table class="data-table">
@foreach ($submissions as $submission)
<tr>
@foreach ($fields as $key => $field)
<td>
<a
href="{{ cp_route('forms.submissions.show', [$form->handle(), $submission['id']]) }}"
>
{{ Arr::get($submission, $field) }}
</a>
</td>
@endforeach

<td class="ltr:text-right rtl:text-left">
{{ $submission['date']->diffInDays() <= 14 ? $submission['date']->diffForHumans() : $submission['date']->asVueComponent() }}
</td>
</tr>
@endforeach
</table>
@endif
</div>
</div>
<form-widget
form="{{ $form->handle() }}"
title="{{ $title }}"
:fields='@json($fields)'
:initial-per-page="{{ $limit }}"
></form-widget>
1 change: 1 addition & 0 deletions src/Forms/Widget.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ public function html()
'fields' => $this->config('fields', []),
'submissions' => collect($form->submissions())->reverse()->take((int) $this->config('limit', 5))->toArray(),
'title' => $this->config('title', $form->title()),
'limit' => $this->config('limit', 5),
]);
}
}
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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: 2 additions & 0 deletions resources/js/bootstrap/components.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ import AssetContainerList from '../components/AssetContainerList.vue';
import AddonList from '../components/AddonList.vue';
import AddonDetails from '../components/AddonDetails.vue';
import CollectionWidget from '../components/entries/CollectionWidget.vue';
import FormWidget from '../components/forms/FormWidget.vue';
import SvgIcon from '../components/SvgIcon.vue';
import FileIcon from '../components/FileIcon.vue';
import LoadingGraphic from '../components/LoadingGraphic.vue';
Expand DownExpand Up@@ -115,6 +116,7 @@ export default function registerGlobalComponents(app) {

// Widgets
app.component('collection-widget', CollectionWidget);
app.component('form-widget', FormWidget);

// Reusable
app.component('svg-icon', SvgIcon);
Expand Down
54 changes: 54 additions & 0 deletions resources/js/components/DateFormatter.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,12 +35,66 @@ export default class DateFormatter {

toString() {
try {
if (this.#options.relative) return this.#formatRelative();

return Intl.DateTimeFormat(this.locale, this.#options).format(this.#date);
} catch (e) {
return 'Invalid Date';
}
}

#formatRelative() {
let specificity = this.#options.relative;
specificity = !specificity || specificity === true ? 'year' : specificity;

const now = new Date();
const diff = now - this.#date;
const seconds = Math.abs(Math.floor(diff / 1000));
const minutes = Math.abs(Math.floor(seconds / 60));
const hours = Math.abs(Math.floor(minutes / 60));
const days = Math.abs(Math.floor(hours / 24));
const weeks = Math.abs(Math.floor(days / 7));
const months = Math.abs(Math.floor(days / 30));
const years = Math.abs(Math.floor(days / 365));

const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric: 'auto' });

// Always show seconds if less than a minute
if (seconds < 60) return rtf.format(-Math.sign(diff) * seconds, 'second');

// Show minutes if less than an hour and specificity allows
if (minutes < 60 && ['minute', 'hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * minutes, 'minute');
}

// Show hours if less than a day and specificity allows
if (hours < 24 && ['hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * hours, 'hour');
}

// Show days if less than a week and specificity allows
if (days < 7 && ['day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * days, 'day');
}

// Show weeks if less than a month and specificity allows
if (weeks < 4 && ['week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * weeks, 'week');
}

// Show months if less than a year and specificity allows
if (months < 12 && ['month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * months, 'month');
}

// Show years if specificity allows
if (specificity === 'year') {
return rtf.format(-Math.sign(diff) * years, 'year');
}

return new DateFormatter(this.#date, this.#options.fallback || 'datetime').toString();
}

static format(date, options) {
return new DateFormatter(date, options).toString();
}
Expand Down
82 changes: 82 additions & 0 deletions resources/js/components/forms/FormWidget.vue
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
<script>
import Listing from '../Listing.vue';
import DateFormatter from '@statamic/components/DateFormatter.js';
import { Widget } from '@statamic/ui';

export default {
mixins: [Listing],

components: {
Widget,
},

props: {
form: { type: String, required: true },
fields: { type: Array, default: () => [] },
title: { type: String },
},

data() {
return {
cols: [
...this.fields.map((field) => ({ label: field, field, visible: true })),
{ label: 'Date', field: 'date', visible: true },
],
listingKey: 'submissions',
requestUrl: cp_url(`forms/${this.form}/submissions`),
};
},

methods: {
formatDate(value) {
return DateFormatter.format(value, { relative: 'hour' }).toString();
},
},
};
</script>

<template>
<Widget :title="title" icon="forms">
<data-list v-if="!initializing && items.length" :rows="items" :columns="cols" :sort="false" class="w-full">
<div v-if="initializing" class="loading">
<loading-graphic />
</div>

<data-list-table
v-else
:loading="loading"
unstyled
class="[&_td]:px-0.5 [&_td]:py-0.75 [&_td]:text-sm [&_thead]:hidden"
>
<template v-for="field in fields" #[`cell-${field}`]="{ row: submission }">
<a
:href="cp_url(`forms/${form}/submissions/${submission.id}`)"
class="line-clamp-1 overflow-hidden text-ellipsis"
>
{{ submission[field] }}
</a>
</template>
<template #cell-date="{ row: submission }">
<div
class="text-end font-mono text-xs whitespace-nowrap text-gray-500 antialiased"
v-html="formatDate(submission.datestamp)"
/>
</template>
</data-list-table>
</data-list>

<p v-if="!initializing && !items.length" class="p-3 text-center text-sm text-gray-600">
{{ __('This form is awaiting responses') }}
</p>

<template #actions>
<data-list-pagination
v-if="meta.last_page != 1"
:resource-meta="meta"
@page-selected="selectPage"
:scroll-to-top="false"
:show-page-links="false"
/>
</template>
</Widget>
</template>
2 changes: 1 addition & 1 deletion resources/js/components/ui/Widget.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ defineProps({

<template>
<ui-card inset>
<div class="flex h-full flex-col justify-between">
<div class="flex h-full flex-col justify-between min-h-54">
<div>
<header class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
Expand Down
3 changes: 2 additions & 1 deletion resources/js/components/ui/index.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,5 +5,6 @@ import { default as DatePicker } from './Datepicker/Index.vue';
import { default as DateRangePicker } from './DateRangePicker/Index.vue';
import { default as TimePicker } from './TimePicker/Index.vue';
import { default as Button } from './Button/Index.vue';
import { default as Widget } from './Widget.vue';

export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button };
export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button, Widget };
103 changes: 103 additions & 0 deletions resources/js/tests/components/DateFormatter.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -208,3 +208,106 @@ test.each([
test('an invalid preset throws an error', () => {
expect(() => new DateFormatter().options('foo')).toThrow('Invalid date format: foo');
});

test.each([
// All the different diffs of time. It defaults to 'year' specificity.
['now', 'en', { relative: true }, '2021-12-25T12:13:14Z', 'now'],
['seconds ago', 'en', { relative: true }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['minute ago', 'en', { relative: true }, '2021-12-25T12:12:14Z', '1 minute ago'],
['minutes ago', 'en', { relative: true }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['hour ago', 'en', { relative: true }, '2021-12-25T11:13:14Z', '1 hour ago'],
['hours ago', 'en', { relative: true }, '2021-12-25T10:13:14Z', '2 hours ago'],
['day ago', 'en', { relative: true }, '2021-12-24T12:13:14Z', 'yesterday'],
['days ago', 'en', { relative: true }, '2021-12-23T12:13:14Z', '2 days ago'],
['week ago', 'en', { relative: true }, '2021-12-18T12:13:14Z', 'last week'],
['weeks ago', 'en', { relative: true }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['month ago', 'en', { relative: true }, '2021-11-25T12:13:14Z', 'last month'],
['months ago', 'en', { relative: true }, '2021-10-25T12:13:14Z', '2 months ago'],
['year ago', 'en', { relative: true }, '2020-12-25T12:13:14Z', 'last year'],
['years ago', 'en', { relative: true }, '2019-12-25T12:13:14Z', '2 years ago'],

// Same in another locale
['de, now', 'de', { relative: true }, '2021-12-25T12:13:14Z', 'jetzt'],
['de, seconds ago', 'de', { relative: true }, '2021-12-25T12:13:12Z', 'vor 2 Sekunden'],
['de, minute ago', 'de', { relative: true }, '2021-12-25T12:12:14Z', 'vor 1 Minute'],
['de, minutes ago', 'de', { relative: true }, '2021-12-25T12:11:14Z', 'vor 2 Minuten'],
['de, hour ago', 'de', { relative: true }, '2021-12-25T11:13:14Z', 'vor 1 Stunde'],
['de, hours ago', 'de', { relative: true }, '2021-12-25T10:13:14Z', 'vor 2 Stunden'],
['de, day ago', 'de', { relative: true }, '2021-12-24T12:13:14Z', 'gestern'],
['de, days ago', 'de', { relative: true }, '2021-12-23T12:13:14Z', 'vorgestern'],
['de, week ago', 'de', { relative: true }, '2021-12-18T12:13:14Z', 'letzte Woche'],
['de, weeks ago', 'de', { relative: true }, '2021-12-11T12:13:14Z', 'vor 2 Wochen'],
['de, month ago', 'de', { relative: true }, '2021-11-25T12:13:14Z', 'letzten Monat'],
['de, months ago', 'de', { relative: true }, '2021-10-25T12:13:14Z', 'vor 2 Monaten'],
['de, year ago', 'de', { relative: true }, '2020-12-25T12:13:14Z', 'letztes Jahr'],
['de, years ago', 'de', { relative: true }, '2019-12-25T12:13:14Z', 'vor 2 Jahren'],

// All different specificities
['years ago, spec year', 'en', { relative: 'year' }, '2019-12-25T12:13:14Z', '2 years ago'],
['years ago, spec month', 'en', { relative: 'month' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec week', 'en', { relative: 'week' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec day', 'en', { relative: 'day' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec hour', 'en', { relative: 'hour' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec minute', 'en', { relative: 'minute' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec second', 'en', { relative: 'second' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['months ago, spec year', 'en', { relative: 'year' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec month', 'en', { relative: 'month' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec week', 'en', { relative: 'week' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec day', 'en', { relative: 'day' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec hour', 'en', { relative: 'hour' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec minute', 'en', { relative: 'minute' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec second', 'en', { relative: 'second' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['weeks ago, spec year', 'en', { relative: 'year' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec month', 'en', { relative: 'month' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec week', 'en', { relative: 'week' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec day', 'en', { relative: 'day' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec hour', 'en', { relative: 'hour' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec minute', 'en', { relative: 'minute' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec second', 'en', { relative: 'second' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['days ago, spec year', 'en', { relative: 'year' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec month', 'en', { relative: 'month' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec week', 'en', { relative: 'week' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec day', 'en', { relative: 'day' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec hour', 'en', { relative: 'hour' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec minute', 'en', { relative: 'minute' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec second', 'en', { relative: 'second' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['hours ago, spec year', 'en', { relative: 'year' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec month', 'en', { relative: 'month' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec week', 'en', { relative: 'week' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec day', 'en', { relative: 'day' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['hours ago, spec second', 'en', { relative: 'second' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['minutes ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:11:14Z', '12/25/2021, 12:11 PM'],
['seconds ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
[
'fallback of datetime preset',
'en',
{ relative: 'second', fallback: 'datetime' },
'2021-12-20T12:13:14Z',
'12/20/2021, 12:13 PM',
],
['fallback of time preset', 'en', { relative: 'second', fallback: 'time' }, '2021-12-20T12:13:14Z', '12:13 PM'],
[
'fallback of options',
'en',
{ relative: 'second', fallback: { dateStyle: 'long', timeStyle: 'long' } },
'2021-12-20T12:13:14Z',
'December 20, 2021 at 12:13:14 PM UTC',
],
])('it can use relative format (%s)', (label, locale, options, date, expected) => {
setNavigatorLanguage(locale);
expect(new DateFormatter().format(date, options)).toBe(expected);
});
51 changes: 6 additions & 45 deletions resources/views/forms/widget.blade.php
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,6 @@
@php
use function Statamic\trans as __;
@endphp

@php
use Statamic\Support\Arr;
@endphp

<div class="card overflow-hidden p-0">
<div class="flex items-center justify-between border-b p-4 dark:border-b dark:border-dark-900 dark:bg-dark-650">
<h2>
<a class="flex items-center" href="{{ $form->showUrl() }}">
<div class="h-6 w-6 text-gray-800 dark:text-dark-200 ltr:mr-2 rtl:ml-2">
@cp_svg('icons/light/drawer-file')
</div>
<span v-pre>{{ $title }}</span>
</a>
</h2>
</div>
<div>
@if (! $submissions)
<p class="p-4 text-sm text-gray-600">{{ __('This form is awaiting responses') }}</p>
@else
<table class="data-table">
@foreach ($submissions as $submission)
<tr>
@foreach ($fields as $key => $field)
<td>
<a
href="{{ cp_route('forms.submissions.show', [$form->handle(), $submission['id']]) }}"
>
{{ Arr::get($submission, $field) }}
</a>
</td>
@endforeach

<td class="ltr:text-right rtl:text-left">
{{ $submission['date']->diffInDays() <= 14 ? $submission['date']->diffForHumans() : $submission['date']->asVueComponent() }}
</td>
</tr>
@endforeach
</table>
@endif
</div>
</div>
<form-widget
form="{{ $form->handle() }}"
title="{{ $title }}"
:fields='@json($fields)'
:initial-per-page="{{ $limit }}"
></form-widget>
1 change: 1 addition & 0 deletions src/Forms/Widget.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ public function html()
'fields' => $this->config('fields', []),
'submissions' => collect($form->submissions())->reverse()->take((int) $this->config('limit', 5))->toArray(),
'title' => $this->config('title', $form->title()),
'limit' => $this->config('limit', 5),
]);
}
}
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
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: 2 additions & 0 deletions resources/js/bootstrap/components.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ import AssetContainerList from '../components/AssetContainerList.vue';
import AddonList from '../components/AddonList.vue';
import AddonDetails from '../components/AddonDetails.vue';
import CollectionWidget from '../components/entries/CollectionWidget.vue';
import FormWidget from '../components/forms/FormWidget.vue';
import SvgIcon from '../components/SvgIcon.vue';
import FileIcon from '../components/FileIcon.vue';
import LoadingGraphic from '../components/LoadingGraphic.vue';
Expand DownExpand Up@@ -115,6 +116,7 @@ export default function registerGlobalComponents(app) {

// Widgets
app.component('collection-widget', CollectionWidget);
app.component('form-widget', FormWidget);

// Reusable
app.component('svg-icon', SvgIcon);
Expand Down
54 changes: 54 additions & 0 deletions resources/js/components/DateFormatter.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,12 +35,66 @@ export default class DateFormatter {

toString() {
try {
if (this.#options.relative) return this.#formatRelative();

return Intl.DateTimeFormat(this.locale, this.#options).format(this.#date);
} catch (e) {
return 'Invalid Date';
}
}

#formatRelative() {
let specificity = this.#options.relative;
specificity = !specificity || specificity === true ? 'year' : specificity;

const now = new Date();
const diff = now - this.#date;
const seconds = Math.abs(Math.floor(diff / 1000));
const minutes = Math.abs(Math.floor(seconds / 60));
const hours = Math.abs(Math.floor(minutes / 60));
const days = Math.abs(Math.floor(hours / 24));
const weeks = Math.abs(Math.floor(days / 7));
const months = Math.abs(Math.floor(days / 30));
const years = Math.abs(Math.floor(days / 365));

const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric: 'auto' });

// Always show seconds if less than a minute
if (seconds < 60) return rtf.format(-Math.sign(diff) * seconds, 'second');

// Show minutes if less than an hour and specificity allows
if (minutes < 60 && ['minute', 'hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * minutes, 'minute');
}

// Show hours if less than a day and specificity allows
if (hours < 24 && ['hour', 'day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * hours, 'hour');
}

// Show days if less than a week and specificity allows
if (days < 7 && ['day', 'week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * days, 'day');
}

// Show weeks if less than a month and specificity allows
if (weeks < 4 && ['week', 'month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * weeks, 'week');
}

// Show months if less than a year and specificity allows
if (months < 12 && ['month', 'year'].includes(specificity)) {
return rtf.format(-Math.sign(diff) * months, 'month');
}

// Show years if specificity allows
if (specificity === 'year') {
return rtf.format(-Math.sign(diff) * years, 'year');
}

return new DateFormatter(this.#date, this.#options.fallback || 'datetime').toString();
}

static format(date, options) {
return new DateFormatter(date, options).toString();
}
Expand Down
82 changes: 82 additions & 0 deletions resources/js/components/forms/FormWidget.vue
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
<script>
import Listing from '../Listing.vue';
import DateFormatter from '@statamic/components/DateFormatter.js';
import { Widget } from '@statamic/ui';

export default {
mixins: [Listing],

components: {
Widget,
},

props: {
form: { type: String, required: true },
fields: { type: Array, default: () => [] },
title: { type: String },
},

data() {
return {
cols: [
...this.fields.map((field) => ({ label: field, field, visible: true })),
{ label: 'Date', field: 'date', visible: true },
],
listingKey: 'submissions',
requestUrl: cp_url(`forms/${this.form}/submissions`),
};
},

methods: {
formatDate(value) {
return DateFormatter.format(value, { relative: 'hour' }).toString();
},
},
};
</script>

<template>
<Widget :title="title" icon="forms">
<data-list v-if="!initializing && items.length" :rows="items" :columns="cols" :sort="false" class="w-full">
<div v-if="initializing" class="loading">
<loading-graphic />
</div>

<data-list-table
v-else
:loading="loading"
unstyled
class="[&_td]:px-0.5 [&_td]:py-0.75 [&_td]:text-sm [&_thead]:hidden"
>
<template v-for="field in fields" #[`cell-${field}`]="{ row: submission }">
<a
:href="cp_url(`forms/${form}/submissions/${submission.id}`)"
class="line-clamp-1 overflow-hidden text-ellipsis"
>
{{ submission[field] }}
</a>
</template>
<template #cell-date="{ row: submission }">
<div
class="text-end font-mono text-xs whitespace-nowrap text-gray-500 antialiased"
v-html="formatDate(submission.datestamp)"
/>
</template>
</data-list-table>
</data-list>

<p v-if="!initializing && !items.length" class="p-3 text-center text-sm text-gray-600">
{{ __('This form is awaiting responses') }}
</p>

<template #actions>
<data-list-pagination
v-if="meta.last_page != 1"
:resource-meta="meta"
@page-selected="selectPage"
:scroll-to-top="false"
:show-page-links="false"
/>
</template>
</Widget>
</template>
2 changes: 1 addition & 1 deletion resources/js/components/ui/Widget.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ defineProps({

<template>
<ui-card inset>
<div class="flex h-full flex-col justify-between">
<div class="flex h-full flex-col justify-between min-h-54">
<div>
<header class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
Expand Down
3 changes: 2 additions & 1 deletion resources/js/components/ui/index.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,5 +5,6 @@ import { default as DatePicker } from './Datepicker/Index.vue';
import { default as DateRangePicker } from './DateRangePicker/Index.vue';
import { default as TimePicker } from './TimePicker/Index.vue';
import { default as Button } from './Button/Index.vue';
import { default as Widget } from './Widget.vue';

export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button };
export { WithField, Calendar, Card, DatePicker, DateRangePicker, TimePicker, Button, Widget };
103 changes: 103 additions & 0 deletions resources/js/tests/components/DateFormatter.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -208,3 +208,106 @@ test.each([
test('an invalid preset throws an error', () => {
expect(() => new DateFormatter().options('foo')).toThrow('Invalid date format: foo');
});

test.each([
// All the different diffs of time. It defaults to 'year' specificity.
['now', 'en', { relative: true }, '2021-12-25T12:13:14Z', 'now'],
['seconds ago', 'en', { relative: true }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['minute ago', 'en', { relative: true }, '2021-12-25T12:12:14Z', '1 minute ago'],
['minutes ago', 'en', { relative: true }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['hour ago', 'en', { relative: true }, '2021-12-25T11:13:14Z', '1 hour ago'],
['hours ago', 'en', { relative: true }, '2021-12-25T10:13:14Z', '2 hours ago'],
['day ago', 'en', { relative: true }, '2021-12-24T12:13:14Z', 'yesterday'],
['days ago', 'en', { relative: true }, '2021-12-23T12:13:14Z', '2 days ago'],
['week ago', 'en', { relative: true }, '2021-12-18T12:13:14Z', 'last week'],
['weeks ago', 'en', { relative: true }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['month ago', 'en', { relative: true }, '2021-11-25T12:13:14Z', 'last month'],
['months ago', 'en', { relative: true }, '2021-10-25T12:13:14Z', '2 months ago'],
['year ago', 'en', { relative: true }, '2020-12-25T12:13:14Z', 'last year'],
['years ago', 'en', { relative: true }, '2019-12-25T12:13:14Z', '2 years ago'],

// Same in another locale
['de, now', 'de', { relative: true }, '2021-12-25T12:13:14Z', 'jetzt'],
['de, seconds ago', 'de', { relative: true }, '2021-12-25T12:13:12Z', 'vor 2 Sekunden'],
['de, minute ago', 'de', { relative: true }, '2021-12-25T12:12:14Z', 'vor 1 Minute'],
['de, minutes ago', 'de', { relative: true }, '2021-12-25T12:11:14Z', 'vor 2 Minuten'],
['de, hour ago', 'de', { relative: true }, '2021-12-25T11:13:14Z', 'vor 1 Stunde'],
['de, hours ago', 'de', { relative: true }, '2021-12-25T10:13:14Z', 'vor 2 Stunden'],
['de, day ago', 'de', { relative: true }, '2021-12-24T12:13:14Z', 'gestern'],
['de, days ago', 'de', { relative: true }, '2021-12-23T12:13:14Z', 'vorgestern'],
['de, week ago', 'de', { relative: true }, '2021-12-18T12:13:14Z', 'letzte Woche'],
['de, weeks ago', 'de', { relative: true }, '2021-12-11T12:13:14Z', 'vor 2 Wochen'],
['de, month ago', 'de', { relative: true }, '2021-11-25T12:13:14Z', 'letzten Monat'],
['de, months ago', 'de', { relative: true }, '2021-10-25T12:13:14Z', 'vor 2 Monaten'],
['de, year ago', 'de', { relative: true }, '2020-12-25T12:13:14Z', 'letztes Jahr'],
['de, years ago', 'de', { relative: true }, '2019-12-25T12:13:14Z', 'vor 2 Jahren'],

// All different specificities
['years ago, spec year', 'en', { relative: 'year' }, '2019-12-25T12:13:14Z', '2 years ago'],
['years ago, spec month', 'en', { relative: 'month' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec week', 'en', { relative: 'week' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec day', 'en', { relative: 'day' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec hour', 'en', { relative: 'hour' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec minute', 'en', { relative: 'minute' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['years ago, spec second', 'en', { relative: 'second' }, '2019-12-25T12:13:14Z', '12/25/2019, 12:13 PM'],
['months ago, spec year', 'en', { relative: 'year' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec month', 'en', { relative: 'month' }, '2021-10-25T12:13:14Z', '2 months ago'],
['months ago, spec week', 'en', { relative: 'week' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec day', 'en', { relative: 'day' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec hour', 'en', { relative: 'hour' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec minute', 'en', { relative: 'minute' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['months ago, spec second', 'en', { relative: 'second' }, '2021-10-25T12:13:14Z', '10/25/2021, 12:13 PM'],
['weeks ago, spec year', 'en', { relative: 'year' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec month', 'en', { relative: 'month' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec week', 'en', { relative: 'week' }, '2021-12-11T12:13:14Z', '2 weeks ago'],
['weeks ago, spec day', 'en', { relative: 'day' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec hour', 'en', { relative: 'hour' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec minute', 'en', { relative: 'minute' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['weeks ago, spec second', 'en', { relative: 'second' }, '2021-12-11T12:13:14Z', '12/11/2021, 12:13 PM'],
['days ago, spec year', 'en', { relative: 'year' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec month', 'en', { relative: 'month' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec week', 'en', { relative: 'week' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec day', 'en', { relative: 'day' }, '2021-12-23T12:13:14Z', '2 days ago'],
['days ago, spec hour', 'en', { relative: 'hour' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec minute', 'en', { relative: 'minute' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['days ago, spec second', 'en', { relative: 'second' }, '2021-12-23T12:13:14Z', '12/23/2021, 12:13 PM'],
['hours ago, spec year', 'en', { relative: 'year' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec month', 'en', { relative: 'month' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec week', 'en', { relative: 'week' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec day', 'en', { relative: 'day' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T10:13:14Z', '2 hours ago'],
['hours ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['hours ago, spec second', 'en', { relative: 'second' }, '2021-12-25T10:13:14Z', '12/25/2021, 10:13 AM'],
['minutes ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:11:14Z', '2 minutes ago'],
['minutes ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:11:14Z', '12/25/2021, 12:11 PM'],
['seconds ago, spec year', 'en', { relative: 'year' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec month', 'en', { relative: 'month' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec week', 'en', { relative: 'week' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec day', 'en', { relative: 'day' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec hour', 'en', { relative: 'hour' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec minute', 'en', { relative: 'minute' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
['seconds ago, spec second', 'en', { relative: 'second' }, '2021-12-25T12:13:12Z', '2 seconds ago'],
[
'fallback of datetime preset',
'en',
{ relative: 'second', fallback: 'datetime' },
'2021-12-20T12:13:14Z',
'12/20/2021, 12:13 PM',
],
['fallback of time preset', 'en', { relative: 'second', fallback: 'time' }, '2021-12-20T12:13:14Z', '12:13 PM'],
[
'fallback of options',
'en',
{ relative: 'second', fallback: { dateStyle: 'long', timeStyle: 'long' } },
'2021-12-20T12:13:14Z',
'December 20, 2021 at 12:13:14 PM UTC',
],
])('it can use relative format (%s)', (label, locale, options, date, expected) => {
setNavigatorLanguage(locale);
expect(new DateFormatter().format(date, options)).toBe(expected);
});
51 changes: 6 additions & 45 deletions resources/views/forms/widget.blade.php
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,6 @@
@php
use function Statamic\trans as __;
@endphp

@php
use Statamic\Support\Arr;
@endphp

<div class="card overflow-hidden p-0">
<div class="flex items-center justify-between border-b p-4 dark:border-b dark:border-dark-900 dark:bg-dark-650">
<h2>
<a class="flex items-center" href="{{ $form->showUrl() }}">
<div class="h-6 w-6 text-gray-800 dark:text-dark-200 ltr:mr-2 rtl:ml-2">
@cp_svg('icons/light/drawer-file')
</div>
<span v-pre>{{ $title }}</span>
</a>
</h2>
</div>
<div>
@if (! $submissions)
<p class="p-4 text-sm text-gray-600">{{ __('This form is awaiting responses') }}</p>
@else
<table class="data-table">
@foreach ($submissions as $submission)
<tr>
@foreach ($fields as $key => $field)
<td>
<a
href="{{ cp_route('forms.submissions.show', [$form->handle(), $submission['id']]) }}"
>
{{ Arr::get($submission, $field) }}
</a>
</td>
@endforeach

<td class="ltr:text-right rtl:text-left">
{{ $submission['date']->diffInDays() <= 14 ? $submission['date']->diffForHumans() : $submission['date']->asVueComponent() }}
</td>
</tr>
@endforeach
</table>
@endif
</div>
</div>
<form-widget
form="{{ $form->handle() }}"
title="{{ $title }}"
:fields='@json($fields)'
:initial-per-page="{{ $limit }}"
></form-widget>
1 change: 1 addition & 0 deletions src/Forms/Widget.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ public function html()
'fields' => $this->config('fields', []),
'submissions' => collect($form->submissions())->reverse()->take((int) $this->config('limit', 5))->toArray(),
'title' => $this->config('title', $form->title()),
'limit' => $this->config('limit', 5),
]);
}
}