feat(episodes): schedule episode with future publication_date by using cache expiration time

- merge publication date fields into one field instanciated with flatpickr datetime picker
- get user timezone to convert user publication_date input to UTC
- remove setPublishedAt() method from episode entity
- add publication pill component to display the episode publication date info
- clear cache after episode insert
- use CI is_really_writable() helper in install instead of is_writable()
- fix latest episodes layout
- update tsconfig to only include ts folders
- update DEPENDENCIES.md to include flatpickr
- add format_duration helper to format episode enclosure duration instead of translating it (causes
translation bug)
- add Time.ts module to convert UTC time to user localized time for episode publication dates
- fix some layout issues
- update php and js dependencies to latest versions

closes #47
This commit is contained in:
Yassine Doghri 2020-10-22 17:41:59 +00:00
parent 0ab17d1075
commit 4f1e773c0f
45 changed files with 556 additions and 308 deletions

View File

@ -37,6 +37,8 @@ Javascript dependencies:
([Free amCharts license](https://github.com/amcharts/amcharts4/blob/master/dist/script/LICENSE)) ([Free amCharts license](https://github.com/amcharts/amcharts4/blob/master/dist/script/LICENSE))
- [Choices.js](https://joshuajohnson.co.uk/Choices/) - [Choices.js](https://joshuajohnson.co.uk/Choices/)
([MIT License](https://github.com/jshjohnson/Choices/blob/master/LICENSE)) ([MIT License](https://github.com/jshjohnson/Choices/blob/master/LICENSE))
- [flatpickr](https://flatpickr.js.org/)
([MIT License](https://github.com/flatpickr/flatpickr/blob/master/LICENSE.md))
Other: Other:

View File

@ -26,7 +26,7 @@ class BaseController extends Controller
* *
* @var array * @var array
*/ */
protected $helpers = ['auth', 'breadcrumb', 'svg', 'components']; protected $helpers = ['auth', 'breadcrumb', 'svg', 'components', 'misc'];
/** /**
* Constructor. * Constructor.

View File

@ -10,6 +10,7 @@ namespace App\Controllers\Admin;
use App\Models\EpisodeModel; use App\Models\EpisodeModel;
use App\Models\PodcastModel; use App\Models\PodcastModel;
use CodeIgniter\I18n\Time;
class Episode extends BaseController class Episode extends BaseController
{ {
@ -95,9 +96,7 @@ class Episode extends BaseController
'enclosure' => 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]', 'enclosure' => 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]',
'image' => 'image' =>
'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]', 'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]',
'publication_date' => 'valid_date[Y-m-d]|permit_empty', 'publication_date' => 'valid_date[Y-m-d H:i]|permit_empty',
'publication_time' =>
'regex_match[/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/]|permit_empty',
]; ];
if (!$this->validate($rules)) { if (!$this->validate($rules)) {
@ -125,11 +124,12 @@ class Episode extends BaseController
'block' => $this->request->getPost('block') == 'yes', 'block' => $this->request->getPost('block') == 'yes',
'created_by' => user(), 'created_by' => user(),
'updated_by' => user(), 'updated_by' => user(),
'published_at' => Time::createFromFormat(
'Y-m-d H:i',
$this->request->getPost('publication_date'),
$this->request->getPost('client_timezone')
)->setTimezone('UTC'),
]); ]);
$newEpisode->setPublishedAt(
$this->request->getPost('publication_date'),
$this->request->getPost('publication_time')
);
$episodeModel = new EpisodeModel(); $episodeModel = new EpisodeModel();
@ -185,9 +185,7 @@ class Episode extends BaseController
'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]|permit_empty', 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]|permit_empty',
'image' => 'image' =>
'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]', 'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]',
'publication_date' => 'valid_date[Y-m-d]|permit_empty', 'publication_date' => 'valid_date[Y-m-d H:i]|permit_empty',
'publication_time' =>
'regex_match[/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/]|permit_empty',
]; ];
if (!$this->validate($rules)) { if (!$this->validate($rules)) {
@ -210,10 +208,11 @@ class Episode extends BaseController
: null; : null;
$this->episode->type = $this->request->getPost('type'); $this->episode->type = $this->request->getPost('type');
$this->episode->block = $this->request->getPost('block') == 'yes'; $this->episode->block = $this->request->getPost('block') == 'yes';
$this->episode->setPublishedAt( $this->episode->published_at = Time::createFromFormat(
'Y-m-d H:i',
$this->request->getPost('publication_date'), $this->request->getPost('publication_date'),
$this->request->getPost('publication_time') $this->request->getPost('client_timezone')
); )->setTimezone('UTC');
$this->episode->updated_by = user(); $this->episode->updated_by = user();
$enclosure = $this->request->getFile('enclosure'); $enclosure = $this->request->getFile('enclosure');

View File

@ -388,11 +388,8 @@ class Podcast extends BaseController
: $nsItunes->block === 'yes', : $nsItunes->block === 'yes',
'created_by' => user(), 'created_by' => user(),
'updated_by' => user(), 'updated_by' => user(),
'published_at' => strtotime($item->pubDate),
]); ]);
$newEpisode->setPublishedAt(
date('Y-m-d', strtotime($item->pubDate)),
date('H:i:s', strtotime($item->pubDate))
);
$episodeModel = new EpisodeModel(); $episodeModel = new EpisodeModel();

View File

@ -26,7 +26,7 @@ class BaseController extends Controller
* *
* @var array * @var array
*/ */
protected $helpers = ['analytics', 'svg', 'components']; protected $helpers = ['analytics', 'svg', 'components', 'misc'];
/** /**
* Constructor. * Constructor.

View File

@ -48,7 +48,8 @@ class Episode extends BaseController
$cacheName = "page_podcast{$this->episode->podcast_id}_episode{$this->episode->id}_{$locale}"; $cacheName = "page_podcast{$this->episode->podcast_id}_episode{$this->episode->id}_{$locale}";
if (!($cachedView = cache($cacheName))) { if (!($cachedView = cache($cacheName))) {
$previousNextEpisodes = (new EpisodeModel())->getPreviousNextEpisodes( $episodeModel = new EpisodeModel();
$previousNextEpisodes = $episodeModel->getPreviousNextEpisodes(
$this->episode, $this->episode,
$this->podcast->type $this->podcast->type
); );
@ -60,9 +61,15 @@ class Episode extends BaseController
'episode' => $this->episode, 'episode' => $this->episode,
]; ];
$secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode(
$this->podcast->id
);
// The page cache is set to a decade so it is deleted manually upon podcast update // The page cache is set to a decade so it is deleted manually upon podcast update
return view('episode', $data, [ return view('episode', $data, [
'cache' => DECADE, 'cache' => $secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE,
'cache_name' => $cacheName, 'cache_name' => $cacheName,
]); ]);
} }

View File

@ -8,6 +8,7 @@
namespace App\Controllers; namespace App\Controllers;
use App\Models\EpisodeModel;
use App\Models\PodcastModel; use App\Models\PodcastModel;
use CodeIgniter\Controller; use CodeIgniter\Controller;
@ -31,15 +32,29 @@ class Feed extends Controller
// If things go wrong the show must go on and the user must be able to download the file // If things go wrong the show must go on and the user must be able to download the file
log_message('critical', $e); log_message('critical', $e);
} }
$cacheName = $cacheName =
"podcast{$podcast->id}_feed" . "podcast{$podcast->id}_feed" .
($service ? "_{$service['slug']}" : ''); ($service ? "_{$service['slug']}" : '');
if (!($found = cache($cacheName))) { if (!($found = cache($cacheName))) {
$found = get_rss_feed( $found = get_rss_feed(
$podcast, $podcast,
$service ? '?s=' . urlencode($service['name']) : '' $service ? '?s=' . urlencode($service['name']) : ''
); );
cache()->save($cacheName, $found, DECADE);
// The page cache is set to expire after next episode publication or a decade by default so it is deleted manually upon podcast update
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
$podcast->id
);
cache()->save(
$cacheName,
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
} }
return $this->response->setXML($found); return $this->response->setXML($found);
} }

View File

@ -48,7 +48,7 @@ class Install extends Controller
} }
// Check if the created .env file is writable to continue install process // Check if the created .env file is writable to continue install process
if (is_writable(ROOTPATH . '.env')) { if (is_really_writable(ROOTPATH . '.env')) {
try { try {
$dotenv->required([ $dotenv->required([
'app.baseURL', 'app.baseURL',

View File

@ -113,7 +113,7 @@ class Podcast extends BaseController
'podcast' => $this->podcast, 'podcast' => $this->podcast,
'episodesNav' => $episodesNavigation, 'episodesNav' => $episodesNavigation,
'activeQuery' => $activeQuery, 'activeQuery' => $activeQuery,
'episodes' => (new EpisodeModel())->getPodcastEpisodes( 'episodes' => $episodeModel->getPodcastEpisodes(
$this->podcast->id, $this->podcast->id,
$this->podcast->type, $this->podcast->type,
$yearQuery, $yearQuery,
@ -121,8 +121,14 @@ class Podcast extends BaseController
), ),
]; ];
$secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode(
$this->podcast->id
);
return view('podcast', $data, [ return view('podcast', $data, [
'cache' => DECADE, 'cache' => $secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE,
'cache_name' => $cacheName, 'cache_name' => $cacheName,
]); ]);
} }

View File

@ -10,6 +10,7 @@ namespace App\Entities;
use App\Models\PodcastModel; use App\Models\PodcastModel;
use CodeIgniter\Entity; use CodeIgniter\Entity;
use CodeIgniter\I18n\Time;
use League\CommonMark\CommonMarkConverter; use League\CommonMark\CommonMarkConverter;
class Episode extends Entity class Episode extends Entity
@ -49,6 +50,11 @@ class Episode extends Entity
*/ */
protected $description_html; protected $description_html;
/**
* @var boolean
*/
protected $is_published;
protected $dates = [ protected $dates = [
'published_at', 'published_at',
'created_at', 'created_at',
@ -232,17 +238,6 @@ class Episode extends Entity
return $converter->convertToHtml($this->attributes['description']); return $converter->convertToHtml($this->attributes['description']);
} }
public function setPublishedAt($date, $time)
{
if (empty($date)) {
$this->attributes['published_at'] = null;
} else {
$this->attributes['published_at'] = $date . ' ' . $time;
}
return $this;
}
public function setCreatedBy(\App\Entities\User $user) public function setCreatedBy(\App\Entities\User $user)
{ {
$this->attributes['created_by'] = $user->id; $this->attributes['created_by'] = $user->id;
@ -256,4 +251,17 @@ class Episode extends Entity
return $this; return $this;
} }
public function getIsPublished()
{
if ($this->is_published) {
return $this->is_published;
}
helper('date');
$this->is_published = $this->published_at->isBefore(Time::now());
return $this->is_published;
}
} }

View File

@ -256,3 +256,51 @@ if (!function_exists('data_table')) {
} }
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
if (!function_exists('publication_pill')) {
/**
* Data table component
*
* Creates a stylized table.
*
* @param \CodeIgniter\I18n\Time $publicationDate publication datetime of the episode
* @param boolean $isPublished whether or not the episode has been published
* @param string $customClass css class to add to the component
*
* @return string
*/
function publication_pill(
$publicationDate,
$isPublished,
$customClass = ''
): string {
$class = $isPublished
? 'text-green-500 border-green-500'
: 'text-orange-600 border-orange-600';
$label = lang(
$isPublished ? 'Episode.published' : 'Episode.scheduled',
[
'<time
pubdate
datetime="' .
$publicationDate->format(DateTime::ATOM) .
'"
title="' .
$publicationDate .
'">' .
lang('Common.mediumDate', [$publicationDate]) .
'</time>',
]
);
return '<span class="px-1 border ' .
$class .
' ' .
$customClass .
'">' .
$label .
'</span>';
}
}
// ------------------------------------------------------------------------

View File

@ -143,3 +143,27 @@ function slugify($text)
return $text; return $text;
} }
//--------------------------------------------------------------------
if (!function_exists('format_duration')) {
/**
* Formats duration in seconds to an hh:mm:ss string
*
* @param int $seconds seconds to format
* @param string $separator
*
* @return string
*/
function format_duration($seconds, $separator = ':')
{
return sprintf(
'%02d%s%02d%s%02d',
floor($seconds / 3600),
$separator,
($seconds / 60) % 60,
$separator,
$seconds % 60
);
}
}

View File

@ -14,7 +14,6 @@ return [
'home' => 'Home', 'home' => 'Home',
'explicit' => 'Explicit', 'explicit' => 'Explicit',
'mediumDate' => '{0,date,medium}', 'mediumDate' => '{0,date,medium}',
'duration' => '{0,duration}',
'powered_by' => 'Powered by {castopod}.', 'powered_by' => 'Powered by {castopod}.',
'actions' => 'Actions', 'actions' => 'Actions',
'pageInfo' => 'Page {currentPage} out of {pageCount}', 'pageInfo' => 'Page {currentPage} out of {pageCount}',

View File

@ -22,6 +22,8 @@ return [
'delete' => 'Delete', 'delete' => 'Delete',
'go_to_page' => 'Go to page', 'go_to_page' => 'Go to page',
'create' => 'Add an episode', 'create' => 'Add an episode',
'published' => 'Published on {0}',
'scheduled' => 'Scheduled for {0}',
'form' => [ 'form' => [
'enclosure' => 'Audio file', 'enclosure' => 'Audio file',
'enclosure_hint' => 'Choose an .mp3 or .m4a audio file.', 'enclosure_hint' => 'Choose an .mp3 or .m4a audio file.',
@ -54,11 +56,9 @@ return [
'This text is added at the end of each episode description, it is a good place to input your social links for example.', 'This text is added at the end of each episode description, it is a good place to input your social links for example.',
'publication_section_title' => 'Publication info', 'publication_section_title' => 'Publication info',
'publication_section_subtitle' => '', 'publication_section_subtitle' => '',
'published_at' => [ 'publication_date' => 'Publication date',
'label' => 'Publication date', 'publication_date_hint' =>
'date' => 'Date', 'You can schedule the episode release by setting a future publication date. This field must be formatted as YYYY-MM-DD HH:mm',
'time' => 'Time',
],
'parental_advisory' => [ 'parental_advisory' => [
'label' => 'Parental advisory', 'label' => 'Parental advisory',
'hint' => 'Does the episode contain explicit content?', 'hint' => 'Does the episode contain explicit content?',

View File

@ -14,7 +14,6 @@ return [
'home' => 'Accueil', 'home' => 'Accueil',
'explicit' => 'Explicite', 'explicit' => 'Explicite',
'mediumDate' => '{0,date,medium}', 'mediumDate' => '{0,date,medium}',
'duration' => '{0,duration}',
'powered_by' => 'Propulsé par {castopod}.', 'powered_by' => 'Propulsé par {castopod}.',
'actions' => 'Actions', 'actions' => 'Actions',
'pageInfo' => 'Page {currentPage} sur {pageCount}', 'pageInfo' => 'Page {currentPage} sur {pageCount}',

View File

@ -22,6 +22,8 @@ return [
'delete' => 'Supprimer', 'delete' => 'Supprimer',
'go_to_page' => 'Voir', 'go_to_page' => 'Voir',
'create' => 'Ajouter un épisode', 'create' => 'Ajouter un épisode',
'published' => 'Publié le {0}',
'scheduled' => 'Planifié pour le {0}',
'form' => [ 'form' => [
'enclosure' => 'Fichier audio', 'enclosure' => 'Fichier audio',
'enclosure_hint' => 'Sélectionnez un fichier audio .mp3 ou .m4a.', 'enclosure_hint' => 'Sélectionnez un fichier audio .mp3 ou .m4a.',
@ -54,11 +56,9 @@ return [
'Ce texte est ajouté à la fin de chaque description dépisode, cest un bon endroit pour placer vos liens sociaux par exemple.', 'Ce texte est ajouté à la fin de chaque description dépisode, cest un bon endroit pour placer vos liens sociaux par exemple.',
'publication_section_title' => 'Information de publication', 'publication_section_title' => 'Information de publication',
'publication_section_subtitle' => '', 'publication_section_subtitle' => '',
'published_at' => [ 'publication_date' => 'Date de publication',
'label' => 'Date de publication', 'publication_date_hint' =>
'date' => 'Date', 'Vous pouvez planifier la sortie de lépisode en saisissant une date de publication future. Ce champ doit être au format YYYY-MM-DD HH:mm',
'time' => 'Heure',
],
'parental_advisory' => [ 'parental_advisory' => [
'label' => 'Avertissement parental', 'label' => 'Avertissement parental',
'hint' => 'Lépisode contient-il un contenu explicite?', 'hint' => 'Lépisode contient-il un contenu explicite?',

View File

@ -12,6 +12,7 @@ return [
'messages' => [ 'messages' => [
'wrongPasswordError' => 'wrongPasswordError' =>
'Le mot de passe que vous avez saisi est invalide.', 'Le mot de passe que vous avez saisi est invalide.',
'passwordChangeSuccess' => 'Le mot de passe a été modifié avec succès!', 'passwordChangeSuccess' =>
'Le mot de passe a été modifié avec succès!',
], ],
]; ];

View File

@ -57,32 +57,21 @@ class EpisodeModel extends Model
]; ];
protected $validationMessages = []; protected $validationMessages = [];
protected $afterInsert = ['writeEnclosureMetadata']; protected $afterInsert = ['writeEnclosureMetadata', 'clearCache'];
// clear cache beforeUpdate because if slug changes, so will the episode link // clear cache beforeUpdate because if slug changes, so will the episode link
protected $beforeUpdate = ['clearCache']; protected $beforeUpdate = ['clearCache'];
protected $afterUpdate = ['writeEnclosureMetadata']; protected $afterUpdate = ['writeEnclosureMetadata'];
protected $beforeDelete = ['clearCache']; protected $beforeDelete = ['clearCache'];
protected function writeEnclosureMetadata(array $data)
{
helper('id3');
$episode = (new EpisodeModel())->find(
is_array($data['id']) ? $data['id'][0] : $data['id']
);
write_enclosure_tags($episode);
return $data;
}
public function getEpisodeBySlug($podcastId, $episodeSlug) public function getEpisodeBySlug($podcastId, $episodeSlug)
{ {
if (!($found = cache("podcast{$podcastId}_episode@{$episodeSlug}"))) { if (!($found = cache("podcast{$podcastId}_episode@{$episodeSlug}"))) {
$found = $this->where([ $found = $this->where([
'podcast_id' => $podcastId, 'podcast_id' => $podcastId,
'slug' => $episodeSlug, 'slug' => $episodeSlug,
])->first(); ])
->where('`published_at` <= NOW()', null, false)
->first();
cache()->save( cache()->save(
"podcast{$podcastId}_episode@{$episodeSlug}", "podcast{$podcastId}_episode@{$episodeSlug}",
@ -120,6 +109,7 @@ class EpisodeModel extends Model
'podcast_id' => $episode->podcast_id, 'podcast_id' => $episode->podcast_id,
$sortNumberField . ' <' => $sortNumberValue, $sortNumberField . ' <' => $sortNumberValue,
]) ])
->where('`published_at` <= NOW()', null, false)
->first(); ->first();
$nextData = $this->orderBy('(' . $sortNumberField . ') ASC') $nextData = $this->orderBy('(' . $sortNumberField . ') ASC')
@ -127,6 +117,7 @@ class EpisodeModel extends Model
'podcast_id' => $episode->podcast_id, 'podcast_id' => $episode->podcast_id,
$sortNumberField . ' >' => $sortNumberValue, $sortNumberField . ' >' => $sortNumberValue,
]) ])
->where('`published_at` <= NOW()', null, false)
->first(); ->first();
return [ return [
@ -160,7 +151,9 @@ class EpisodeModel extends Model
); );
if (!($found = cache($cacheName))) { if (!($found = cache($cacheName))) {
$where = ['podcast_id' => $podcastId]; $where = [
'podcast_id' => $podcastId,
];
if ($year) { if ($year) {
$where['YEAR(published_at)'] = $year; $where['YEAR(published_at)'] = $year;
$where['season_number'] = null; $where['season_number'] = null;
@ -172,15 +165,27 @@ class EpisodeModel extends Model
if ($podcastType == 'serial') { if ($podcastType == 'serial') {
// podcast is serial // podcast is serial
$found = $this->where($where) $found = $this->where($where)
->where('`published_at` <= NOW()', null, false)
->orderBy('season_number DESC, number ASC') ->orderBy('season_number DESC, number ASC')
->findAll(); ->findAll();
} else { } else {
$found = $this->where($where) $found = $this->where($where)
->where('`published_at` <= NOW()', null, false)
->orderBy('published_at', 'DESC') ->orderBy('published_at', 'DESC')
->findAll(); ->findAll();
} }
cache()->save($cacheName, $found, DECADE); $secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
$podcastId
);
cache()->save(
$cacheName,
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
} }
return $found; return $found;
@ -197,12 +202,23 @@ class EpisodeModel extends Model
'season_number' => null, 'season_number' => null,
$this->deletedField => null, $this->deletedField => null,
]) ])
->where('`published_at` <= NOW()', null, false)
->groupBy('year') ->groupBy('year')
->orderBy('year', 'DESC') ->orderBy('year', 'DESC')
->get() ->get()
->getResultArray(); ->getResultArray();
cache()->save("podcast{$podcastId}_years", $found, DECADE); $secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
$podcastId
);
cache()->save(
"podcast{$podcastId}_years",
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
} }
return $found; return $found;
@ -219,12 +235,23 @@ class EpisodeModel extends Model
'season_number is not' => null, 'season_number is not' => null,
$this->deletedField => null, $this->deletedField => null,
]) ])
->where('`published_at` <= NOW()', null, false)
->groupBy('season_number') ->groupBy('season_number')
->orderBy('season_number', 'ASC') ->orderBy('season_number', 'ASC')
->get() ->get()
->getResultArray(); ->getResultArray();
cache()->save("podcast{$podcastId}_seasons", $found, DECADE); $secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
$podcastId
);
cache()->save(
"podcast{$podcastId}_seasons",
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
} }
return $found; return $found;
@ -264,6 +291,43 @@ class EpisodeModel extends Model
return $defaultQuery; return $defaultQuery;
} }
/**
* Returns the timestamp difference in seconds between the next episode to publish and the current timestamp
* Returns false if there's no episode to publish
*
* @param int $podcastId
*
* @return int|false seconds
*/
public function getSecondsToNextUnpublishedEpisode(int $podcastId)
{
$result = $this->select(
'TIMESTAMPDIFF(SECOND, NOW(), `published_at`) as timestamp_diff'
)
->where([
'podcast_id' => $podcastId,
])
->where('`published_at` > NOW()', null, false)
->orderBy('published_at', 'asc')
->get()
->getResultArray();
return (int) $result ? $result[0]['timestamp_diff'] : false;
}
protected function writeEnclosureMetadata(array $data)
{
helper('id3');
$episode = (new EpisodeModel())->find(
is_array($data['id']) ? $data['id'][0] : $data['id']
);
write_enclosure_tags($episode);
return $data;
}
protected function clearCache(array $data) protected function clearCache(array $data)
{ {
$episodeModel = new EpisodeModel(); $episodeModel = new EpisodeModel();

View File

@ -59,7 +59,7 @@ class PodcastModel extends Model
]; ];
protected $validationMessages = []; protected $validationMessages = [];
// clear cache before update if by any chance, the podcast name changes, and so will the podcast link // clear cache before update if by any chance, the podcast name changes, so will the podcast link
protected $beforeUpdate = ['clearCache']; protected $beforeUpdate = ['clearCache'];
protected $beforeDelete = ['clearCache']; protected $beforeDelete = ['clearCache'];

View File

@ -1,8 +1,11 @@
import ClientTimezone from "./modules/ClientTimezone";
import DateTimePicker from "./modules/DateTimePicker";
import Dropdown from "./modules/Dropdown"; import Dropdown from "./modules/Dropdown";
import MarkdownEditor from "./modules/MarkdownEditor"; import MarkdownEditor from "./modules/MarkdownEditor";
import MultiSelect from "./modules/MultiSelect"; import MultiSelect from "./modules/MultiSelect";
import SidebarToggler from "./modules/SidebarToggler"; import SidebarToggler from "./modules/SidebarToggler";
import Slugify from "./modules/Slugify"; import Slugify from "./modules/Slugify";
import Time from "./modules/Time";
import Tooltip from "./modules/Tooltip"; import Tooltip from "./modules/Tooltip";
Dropdown(); Dropdown();
@ -11,3 +14,6 @@ MarkdownEditor();
MultiSelect(); MultiSelect();
Slugify(); Slugify();
SidebarToggler(); SidebarToggler();
ClientTimezone();
DateTimePicker();
Time();

View File

@ -68,7 +68,10 @@ const drawXYChart = (chartDivId: string, dataUrl: string | null): void => {
chart.scrollbarX = new am4core.Scrollbar(); chart.scrollbarX = new am4core.Scrollbar();
}; };
const drawXYDurationChart = (chartDivId: string, dataUrl: string | null): void => { const drawXYDurationChart = (
chartDivId: string,
dataUrl: string | null
): void => {
// Create chart instance // Create chart instance
const chart = am4core.create(chartDivId, am4charts.XYChart); const chart = am4core.create(chartDivId, am4charts.XYChart);
am4core.percent(100); am4core.percent(100);
@ -203,7 +206,10 @@ const DrawCharts = (): void => {
drawXYChart(chartDiv.id, chartDiv.getAttribute("data-chart-url")); drawXYChart(chartDiv.id, chartDiv.getAttribute("data-chart-url"));
break; break;
case "xy-duration-chart": case "xy-duration-chart":
drawXYDurationChart(chartDiv.id, chartDiv.getAttribute("data-chart-url")); drawXYDurationChart(
chartDiv.id,
chartDiv.getAttribute("data-chart-url")
);
break; break;
case "xy-series-chart": case "xy-series-chart":
drawXYSeriesChart(chartDiv.id, chartDiv.getAttribute("data-chart-url")); drawXYSeriesChart(chartDiv.id, chartDiv.getAttribute("data-chart-url"));

View File

@ -0,0 +1,11 @@
const ClientTimezone = (): void => {
const input: HTMLInputElement | null = document.querySelector(
"input[name='client_timezone']"
);
if (input) {
input.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
};
export default ClientTimezone;

View File

@ -0,0 +1,41 @@
import flatpickr from "flatpickr";
import "flatpickr/dist/flatpickr.min.css";
/*
* Detects navigator locale 24h time preference
* It works by checking whether hour output contains AM ('1 AM' or '01 h')
*/
const isBrowserLocale24h = () =>
!new Intl.DateTimeFormat(navigator.language, { hour: "numeric" })
.format(0)
.match(/AM/);
const DateTimePicker = (): void => {
const dateTimeContainers: NodeListOf<HTMLInputElement> = document.querySelectorAll(
"input[data-picker='datetime']"
);
for (let i = 0; i < dateTimeContainers.length; i++) {
const dateTimeContainer = dateTimeContainers[i];
const flatpickrInstance = flatpickr(dateTimeContainer, {
enableTime: true,
time_24hr: isBrowserLocale24h(),
});
// convert container UTC date value to user timezone
const dateTime = new Date(dateTimeContainer.value);
const dateUTC = Date.UTC(
dateTime.getFullYear(),
dateTime.getMonth(),
dateTime.getDate(),
dateTime.getHours(),
dateTime.getMinutes()
);
// set converted date as field value
flatpickrInstance.setDate(new Date(dateUTC));
}
};
export default DateTimePicker;

View File

@ -0,0 +1,24 @@
const Time = (): void => {
const timeElements: NodeListOf<HTMLTimeElement> = document.querySelectorAll(
"time"
);
console.log(timeElements);
for (let i = 0; i < timeElements.length; i++) {
const timeElement = timeElements[i];
// convert UTC date value to user timezone
const timeElementDateTime = timeElement.getAttribute("datetime");
// check if timeElementDateTime is not null and not a duration
if (timeElementDateTime && !timeElementDateTime.startsWith("PT")) {
const dateTime = new Date(timeElementDateTime);
// replace <time/> title with localized datetime
timeElement.setAttribute("title", dateTime.toLocaleString());
}
}
};
export default Time;

View File

@ -0,0 +1,3 @@
import Time from "./modules/Time";
Time();

View File

@ -26,7 +26,7 @@
<div class="container flex flex-wrap items-end justify-between px-2 py-10 mx-auto md:px-12 gap-y-6 gap-x-6"> <div class="container flex flex-wrap items-end justify-between px-2 py-10 mx-auto md:px-12 gap-y-6 gap-x-6">
<div class="flex flex-col"> <div class="flex flex-col">
<?= render_breadcrumb('text-gray-300') ?> <?= render_breadcrumb('text-gray-300') ?>
<h1 class="text-3xl leading-none"><?= $this->renderSection( <h1 class="text-3xl"><?= $this->renderSection(
'pageTitle' 'pageTitle'
) ?></h1> ) ?></h1>
</div> </div>

View File

@ -16,6 +16,7 @@
'class' => 'flex flex-col', 'class' => 'flex flex-col',
]) ?> ]) ?>
<?= csrf_field() ?> <?= csrf_field() ?>
<?= form_hidden('client_timezone', 'UTC') ?>
<?= form_section( <?= form_section(
lang('Episode.form.info_section_title'), lang('Episode.form.info_section_title'),
@ -193,35 +194,19 @@
lang('Episode.form.publication_section_subtitle') lang('Episode.form.publication_section_subtitle')
) ?> ) ?>
<?= form_fieldset('', ['class' => 'flex mb-4']) ?> <?= form_label(
<legend><?= lang('Episode.form.published_at.label') ?></legend> lang('Episode.form.publication_date'),
<div class="flex flex-col flex-1"> 'publication_date',
<?= form_label(lang('Episode.form.publication_date'), 'publication_date', [ [],
'class' => 'sr-only', lang('Episode.form.publication_date_hint')
]) ?> ) ?>
<?= form_input([ <?= form_input([
'id' => 'publication_date', 'id' => 'publication_date',
'name' => 'publication_date', 'name' => 'publication_date',
'class' => 'form-input', 'class' => 'form-input mb-4',
'value' => old('publication_date', date('Y-m-d')), 'value' => old('publication_date', date('Y-m-d H:i')),
'type' => 'date', 'data-picker' => 'datetime',
]) ?> ]) ?>
</div>
<div class="flex flex-col flex-1">
<?= form_label(lang('Episode.form.publication_time'), 'publication_time', [
'class' => 'sr-only',
]) ?>
<?= form_input([
'id' => 'publication_time',
'name' => 'publication_time',
'class' => 'form-input',
'value' => old('publication_time', date('H:i')),
'placeholder' => '--:--',
'type' => 'time',
]) ?>
</div>
<?= form_fieldset_close() ?>
<?= form_fieldset('', ['class' => 'flex mb-6 gap-1']) ?> <?= form_fieldset('', ['class' => 'flex mb-6 gap-1']) ?>
<legend> <legend>

View File

@ -16,6 +16,7 @@
'class' => 'flex flex-col', 'class' => 'flex flex-col',
]) ?> ]) ?>
<?= csrf_field() ?> <?= csrf_field() ?>
<?= form_hidden('client_timezone', 'UTC') ?>
<?= form_section( <?= form_section(
lang('Episode.form.info_section_title'), lang('Episode.form.info_section_title'),
@ -197,44 +198,24 @@
lang('Episode.form.publication_section_subtitle') lang('Episode.form.publication_section_subtitle')
) ?> ) ?>
<?= form_fieldset('', ['class' => 'flex mb-4']) ?> <?= form_label(
<legend><?= lang('Episode.form.published_at.label') ?></legend> lang('Episode.form.publication_date'),
<div class="flex flex-col flex-1"> 'publication_date',
<?= form_label(lang('Episode.form.publication_date'), 'publication_date', [ [],
'class' => 'sr-only', lang('Episode.form.publication_date_hint')
]) ?> ) ?>
<?= form_input([ <?= form_input([
'id' => 'publication_date', 'id' => 'publication_date',
'name' => 'publication_date', 'name' => 'publication_date',
'class' => 'form-input', 'class' => 'form-input mb-4',
'value' => old( 'value' => old(
'publication_date', 'publication_date',
$episode->published_at $episode->published_at
? $episode->published_at->format('Y-m-d') ? $episode->published_at->format('Y-m-d H:i')
: '' : ''
), ),
'type' => 'date', 'data-picker' => 'datetime',
]) ?> ]) ?>
</div>
<div class="flex flex-col flex-1">
<?= form_label(lang('Episode.form.publication_time'), 'publication_time', [
'class' => 'sr-only',
]) ?>
<?= form_input([
'id' => 'publication_time',
'name' => 'publication_time',
'class' => 'form-input',
'value' => old(
'publication_time',
$episode->published_at ? $episode->published_at->format('H:i') : ''
),
'placeholder' => '--:--',
'type' => 'time',
]) ?>
</div>
<?= form_fieldset_close() ?>
<?= form_fieldset('', ['class' => 'mb-6']) ?> <?= form_fieldset('', ['class' => 'mb-6']) ?>
<legend> <legend>
@ -288,6 +269,7 @@
<?= form_switch( <?= form_switch(
lang('Episode.form.block') . lang('Episode.form.block') .
hint_tooltip(lang('Episode.form.block_hint'), 'ml-1'), hint_tooltip(lang('Episode.form.block_hint'), 'ml-1'),
['id' => 'block', 'name' => 'block'], ['id' => 'block', 'name' => 'block'],
'yes', 'yes',
old('block', $episode->block) old('block', $episode->block)

View File

@ -97,19 +97,13 @@
</div> </div>
</div> </div>
<div class="mb-2 text-xs"> <div class="mb-2 text-xs">
<time <?= publication_pill(
pubdate
datetime="<?= $episode->published_at->toDateTimeString() ?>"
title="<?= $episode->published_at ?>">
<?= lang('Common.mediumDate', [
$episode->published_at, $episode->published_at,
]) ?> $episode->is_published
</time> ) ?>
<span class="mx-1"></span> <span class="mx-1"></span>
<time datetime="PT<?= $episode->enclosure_duration ?>S"> <time datetime="PT<?= $episode->enclosure_duration ?>S">
<?= lang('Common.duration', [ <?= format_duration($episode->enclosure_duration) ?>
$episode->enclosure_duration,
]) ?>
</time> </time>
</div> </div>
<audio controls preload="none" class="w-full mt-auto"> <audio controls preload="none" class="w-full mt-auto">
@ -126,5 +120,4 @@
<?= $pager->links() ?> <?= $pager->links() ?>
<?= $this->endSection() <?= $this->endSection() ?>
?>

View File

@ -5,7 +5,12 @@
<?= $this->endSection() ?> <?= $this->endSection() ?>
<?= $this->section('pageTitle') ?> <?= $this->section('pageTitle') ?>
<?= $episode->title ?> <?= $episode->title .
publication_pill(
$episode->published_at,
$episode->is_published,
'text-sm ml-2 align-middle'
) ?>
<?= $this->endSection() ?> <?= $this->endSection() ?>
<?= $this->section('content') ?> <?= $this->section('content') ?>

View File

@ -44,5 +44,4 @@
<?= form_close() ?> <?= form_close() ?>
<?= $this->endSection() <?= $this->endSection() ?>
?>

View File

@ -13,5 +13,4 @@
<?= view('admin/_partials/_user_info.php', ['user' => user()]) ?> <?= view('admin/_partials/_user_info.php', ['user' => user()]) ?>
<?= $this->endSection() <?= $this->endSection() ?>
?>

View File

@ -238,7 +238,14 @@
'value' => old('publisher'), 'value' => old('publisher'),
]) ?> ]) ?>
<?= form_label(lang('Podcast.form.copyright'), 'copyright', [], '', true) ?> <?= form_label(
lang('Podcast.form.copyright'),
'copyright',
[],
'',
true
) ?>
<?= form_input([ <?= form_input([
'id' => 'copyright', 'id' => 'copyright',
'name' => 'copyright', 'name' => 'copyright',

View File

@ -10,9 +10,9 @@
</a> </a>
</header> </header>
<?php if ($episodes): ?> <?php if ($episodes): ?>
<div class="flex justify-between gap-4 overflow-x-auto"> <div class="flex p-2 space-x-4 overflow-x-auto">
<?php foreach ($episodes as $episode): ?> <?php foreach ($episodes as $episode): ?>
<article class="flex flex-col w-56 mb-4 bg-white border rounded shadow" style="min-width: 12rem;"> <article class="flex flex-col w-56 bg-white border rounded shadow" style="min-width: 12rem;">
<img <img
src="<?= $episode->image->thumbnail_url ?>" src="<?= $episode->image->thumbnail_url ?>"
alt="<?= $episode->title ?>" class="object-cover" /> alt="<?= $episode->title ?>" class="object-cover" />
@ -61,7 +61,9 @@
<span class="mx-1"></span> <span class="mx-1"></span>
<time <time
pubdate pubdate
datetime="<?= $episode->published_at->toDateTimeString() ?>" datetime="<?= $episode->published_at->format(
DateTime::ATOM
) ?>"
title="<?= $episode->published_at ?>"> title="<?= $episode->published_at ?>">
<?= lang('Common.mediumDate', [ <?= lang('Common.mediumDate', [
$episode->published_at, $episode->published_at,

View File

@ -62,5 +62,4 @@
<?php endif; ?> <?php endif; ?>
</div> </div>
<?= $this->endSection() <?= $this->endSection() ?>
?>

View File

@ -50,5 +50,4 @@
<?= form_close() ?> <?= form_close() ?>
<?= $this->endSection() <?= $this->endSection() ?>
?>

View File

@ -85,5 +85,4 @@
$users $users
) ?> ) ?>
<?= $this->endSection() <?= $this->endSection() ?>
?>

View File

@ -11,6 +11,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="shortcut icon" type="image/png" href="/favicon.ico" /> <link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="/assets/index.css"/> <link rel="stylesheet" href="/assets/index.css"/>
<script src="/assets/podcast.js" type="module" defer></script>
</head> </head>
<body class="flex flex-col min-h-screen mx-auto"> <body class="flex flex-col min-h-screen mx-auto">
@ -85,17 +86,13 @@
<div class="text-sm"> <div class="text-sm">
<time <time
pubdate pubdate
datetime="<?= $episode->published_at->toDateTimeString() ?>" datetime="<?= $episode->published_at->format(DateTime::ATOM) ?>"
title="<?= $episode->published_at ?>"> title="<?= $episode->published_at ?>">
<?= lang('Common.mediumDate', [$episode->published_at]) ?> <?= lang('Common.mediumDate', [$episode->published_at]) ?>
</time> </time>
<span class="mx-1"></span> <span class="mx-1"></span>
<time datetime="PT<?= $episode->enclosure_duration ?>S"> <time datetime="PT<?= $episode->enclosure_duration ?>S">
<?= lang( <?= format_duration($episode->enclosure_duration) ?>
'Common.duration',
[$episode->enclosure_duration],
'en'
) ?>
</time> </time>
</div> </div>
<audio controls preload="none" class="w-full mt-auto"> <audio controls preload="none" class="w-full mt-auto">
@ -110,9 +107,9 @@
</section> </section>
</main> </main>
<footer class="px-2 py-4 border-t "> <footer class="px-2 py-4 border-t ">
<div class="container flex flex-col items-center justify-between mx-auto text-sm md:flex-row "> <div class="container flex flex-col items-center justify-between mx-auto text-xs md:flex-row ">
<?= render_page_links('inline-flex mb-4 md:mb-0') ?> <?= render_page_links('inline-flex mb-4 md:mb-0') ?>
<div class="flex flex-col items-end text-xs"> <div class="flex flex-col items-end">
<p><?= $podcast->copyright ?></p> <p><?= $podcast->copyright ?></p>
<p><?= lang('Common.powered_by', [ <p><?= lang('Common.powered_by', [
'castopod' => 'castopod' =>

View File

@ -20,4 +20,5 @@ Line Number: <?= $exception->getLine() ?>
<?php endif; ?> <?php endif; ?>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif;
?>

View File

@ -13,6 +13,7 @@
<link rel="shortcut icon" type="image/png" href="/favicon.ico" /> <link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="/assets/index.css"/> <link rel="stylesheet" href="/assets/index.css"/>
<link type="application/rss+xml" rel="alternate" title="<?= $podcast->title ?>" href="<?= $podcast->feed_url ?>"/> <link type="application/rss+xml" rel="alternate" title="<?= $podcast->title ?>" href="<?= $podcast->feed_url ?>"/>
<script src="/assets/podcast.js" type="module" defer></script>
</head> </head>
<body class="flex flex-col min-h-screen"> <body class="flex flex-col min-h-screen">
@ -127,7 +128,9 @@
<div class="mb-2 text-xs"> <div class="mb-2 text-xs">
<time <time
pubdate pubdate
datetime="<?= $episode->published_at->toDateTimeString() ?>" datetime="<?= $episode->published_at->format(
DateTime::ATOM
) ?>"
title="<?= $episode->published_at ?>"> title="<?= $episode->published_at ?>">
<?= lang('Common.mediumDate', [ <?= lang('Common.mediumDate', [
$episode->published_at, $episode->published_at,
@ -135,9 +138,9 @@
</time> </time>
<span class="mx-1"></span> <span class="mx-1"></span>
<time datetime="PT<?= $episode->enclosure_duration ?>S"> <time datetime="PT<?= $episode->enclosure_duration ?>S">
<?= lang('Common.duration', [ <?= format_duration(
$episode->enclosure_duration, $episode->enclosure_duration
]) ?> ) ?>
</time> </time>
</div> </div>
<audio controls preload="none" class="w-full mt-auto"> <audio controls preload="none" class="w-full mt-auto">
@ -159,9 +162,9 @@
</section> </section>
</main> </main>
<footer class="px-2 py-4 border-t "> <footer class="px-2 py-4 border-t ">
<div class="container flex flex-col items-center justify-between mx-auto text-sm md:flex-row "> <div class="container flex flex-col items-center justify-between mx-auto text-xs md:flex-row ">
<?= render_page_links('inline-flex mb-4 md:mb-0') ?> <?= render_page_links('inline-flex mb-4 md:mb-0') ?>
<div class="flex flex-col items-center text-xs md:items-end"> <div class="flex flex-col items-center md:items-end">
<p><?= $podcast->copyright ?></p> <p><?= $podcast->copyright ?></p>
<p><?= lang('Common.powered_by', [ <p><?= lang('Common.powered_by', [
'castopod' => 'castopod' =>

19
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "37551523e4097a9341bc00dd317f573d", "content-hash": "58e59ff661eaa3553d3f9f9f88b9d274",
"packages": [ "packages": [
{ {
"name": "codeigniter4/codeigniter4", "name": "codeigniter4/codeigniter4",
@ -12,12 +12,12 @@
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/codeigniter4/CodeIgniter4.git", "url": "https://github.com/codeigniter4/CodeIgniter4.git",
"reference": "13ff147fa4cd9db15888b041ef35bc22ed94252a" "reference": "58993fbbab54a2523be25e8230337b855f465a7a"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/codeigniter4/CodeIgniter4/zipball/13ff147fa4cd9db15888b041ef35bc22ed94252a", "url": "https://api.github.com/repos/codeigniter4/CodeIgniter4/zipball/58993fbbab54a2523be25e8230337b855f465a7a",
"reference": "13ff147fa4cd9db15888b041ef35bc22ed94252a", "reference": "58993fbbab54a2523be25e8230337b855f465a7a",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -53,7 +53,6 @@
}, },
"scripts": { "scripts": {
"post-update-cmd": [ "post-update-cmd": [
"@composer dump-autoload",
"CodeIgniter\\ComposerScripts::postUpdate", "CodeIgniter\\ComposerScripts::postUpdate",
"bash admin/setup.sh" "bash admin/setup.sh"
], ],
@ -75,7 +74,7 @@
"slack": "https://codeigniterchat.slack.com", "slack": "https://codeigniterchat.slack.com",
"issues": "https://github.com/codeigniter4/CodeIgniter4/issues" "issues": "https://github.com/codeigniter4/CodeIgniter4/issues"
}, },
"time": "2020-10-20T18:13:11+00:00" "time": "2020-10-21T16:26:19+00:00"
}, },
{ {
"name": "composer/ca-bundle", "name": "composer/ca-bundle",
@ -805,12 +804,12 @@
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/lonnieezell/myth-auth.git", "url": "https://github.com/lonnieezell/myth-auth.git",
"reference": "e9d6a2f557bd275158e0b84624534b2abeeb539c" "reference": "fe9739e1a410d9a30292faee9e8b6369667241e8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/lonnieezell/myth-auth/zipball/e9d6a2f557bd275158e0b84624534b2abeeb539c", "url": "https://api.github.com/repos/lonnieezell/myth-auth/zipball/fe9739e1a410d9a30292faee9e8b6369667241e8",
"reference": "e9d6a2f557bd275158e0b84624534b2abeeb539c", "reference": "fe9739e1a410d9a30292faee9e8b6369667241e8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -860,7 +859,7 @@
"type": "patreon" "type": "patreon"
} }
], ],
"time": "2020-10-16T18:51:37+00:00" "time": "2020-10-22T03:25:47+00:00"
}, },
{ {
"name": "opawg/user-agents-php", "name": "opawg/user-agents-php",

133
package-lock.json generated
View File

@ -5,9 +5,9 @@
"requires": true, "requires": true,
"dependencies": { "dependencies": {
"@amcharts/amcharts4": { "@amcharts/amcharts4": {
"version": "4.10.7", "version": "4.10.8",
"resolved": "https://registry.npmjs.org/@amcharts/amcharts4/-/amcharts4-4.10.7.tgz", "resolved": "https://registry.npmjs.org/@amcharts/amcharts4/-/amcharts4-4.10.8.tgz",
"integrity": "sha512-XWITAuewadEnkX9XgZTqT6CUn91gCJpvLJYrnSdnwu4GOGV4Siu6esoEb4JEYQYEDCzDIK3zlmOT5+a0fulcTw==", "integrity": "sha512-2xIPHkvuxhsN49btE+K0ThO0CxvEgHC+n2aFa05GLwIH2JKgSjFBmjSvELrEqlEYf2mEPjmKjuYe6d4TgHfGUA==",
"requires": { "requires": {
"@babel/runtime": "^7.6.3", "@babel/runtime": "^7.6.3",
"core-js": "^3.0.0", "core-js": "^3.0.0",
@ -53,16 +53,16 @@
"dev": true "dev": true
}, },
"@babel/core": { "@babel/core": {
"version": "7.12.1", "version": "7.12.3",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.1.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.3.tgz",
"integrity": "sha512-6bGmltqzIJrinwRRdczQsMhruSi9Sqty9Te+/5hudn4Izx/JYRhW1QELpR+CIL0gC/c9A7WroH6FmkDGxmWx3w==", "integrity": "sha512-0qXcZYKZp3/6N2jKYVxZv0aNCsxTSVCiK72DTiTYZAu7sjg73W0/aynWjMbiGd87EQL4WyA8reiJVh92AVla9g==",
"dev": true, "dev": true,
"requires": { "requires": {
"@babel/code-frame": "^7.10.4", "@babel/code-frame": "^7.10.4",
"@babel/generator": "^7.12.1", "@babel/generator": "^7.12.1",
"@babel/helper-module-transforms": "^7.12.1", "@babel/helper-module-transforms": "^7.12.1",
"@babel/helpers": "^7.12.1", "@babel/helpers": "^7.12.1",
"@babel/parser": "^7.12.1", "@babel/parser": "^7.12.3",
"@babel/template": "^7.10.4", "@babel/template": "^7.10.4",
"@babel/traverse": "^7.12.1", "@babel/traverse": "^7.12.1",
"@babel/types": "^7.12.1", "@babel/types": "^7.12.1",
@ -102,6 +102,12 @@
"js-tokens": "^4.0.0" "js-tokens": "^4.0.0"
} }
}, },
"@babel/parser": {
"version": "7.12.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.12.3.tgz",
"integrity": "sha512-kFsOS0IbsuhO5ojF8Hc8z/8vEIOkylVBrjiZUbLTE3XFe0Qi+uu6HjzQixkFaqr0ZPAMZcBVxEwmsnsLPZ2Xsw==",
"dev": true
},
"@babel/types": { "@babel/types": {
"version": "7.12.1", "version": "7.12.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.1.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.1.tgz",
@ -2379,13 +2385,13 @@
"integrity": "sha512-RFwCobxsvZ6j7twS7dHIZQZituMIDJJNHS/qY6iuthVebxS3zhRY+jaC2roEKiAYaVuTcGmX6Luc6YBcf6zJVg==" "integrity": "sha512-RFwCobxsvZ6j7twS7dHIZQZituMIDJJNHS/qY6iuthVebxS3zhRY+jaC2roEKiAYaVuTcGmX6Luc6YBcf6zJVg=="
}, },
"@prettier/plugin-php": { "@prettier/plugin-php": {
"version": "0.15.0", "version": "0.15.1",
"resolved": "https://registry.npmjs.org/@prettier/plugin-php/-/plugin-php-0.15.0.tgz", "resolved": "https://registry.npmjs.org/@prettier/plugin-php/-/plugin-php-0.15.1.tgz",
"integrity": "sha512-OnzCmDTDdWLkm2nsvtiWKip1ePoy+KucY1h9zHDVXIFWBrd+OZATeZZgC7JU7gjly96g86hW1ZHpbF9ip9KHfg==", "integrity": "sha512-uQiaGGXCs0uqpck1LyDU+V4Z50Qqml7ltajPQL+DB43r5aHVawDCSkgLGYZJSb1g+hK5eBmdVBqMa7ED8EBjbA==",
"dev": true, "dev": true,
"requires": { "requires": {
"linguist-languages": "^7.5.1", "linguist-languages": "^7.5.1",
"mem": "^6.0.1", "mem": "^8.0.0",
"php-parser": "3.0.2" "php-parser": "3.0.2"
} }
}, },
@ -3133,13 +3139,13 @@
"dev": true "dev": true
}, },
"@typescript-eslint/eslint-plugin": { "@typescript-eslint/eslint-plugin": {
"version": "4.4.1", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.4.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.5.0.tgz",
"integrity": "sha512-O+8Utz8pb4OmcA+Nfi5THQnQpHSD2sDUNw9AxNHpuYOo326HZTtG8gsfT+EAYuVrFNaLyNb2QnUNkmTRDskuRA==", "integrity": "sha512-mjb/gwNcmDKNt+6mb7Aj/TjKzIJjOPcoCJpjBQC9ZnTRnBt1p4q5dJSSmIqAtsZ/Pff5N+hJlbiPc5bl6QN4OQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"@typescript-eslint/experimental-utils": "4.4.1", "@typescript-eslint/experimental-utils": "4.5.0",
"@typescript-eslint/scope-manager": "4.4.1", "@typescript-eslint/scope-manager": "4.5.0",
"debug": "^4.1.1", "debug": "^4.1.1",
"functional-red-black-tree": "^1.0.1", "functional-red-black-tree": "^1.0.1",
"regexpp": "^3.0.0", "regexpp": "^3.0.0",
@ -3156,55 +3162,55 @@
} }
}, },
"@typescript-eslint/experimental-utils": { "@typescript-eslint/experimental-utils": {
"version": "4.4.1", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.4.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.5.0.tgz",
"integrity": "sha512-Nt4EVlb1mqExW9cWhpV6pd1a3DkUbX9DeyYsdoeziKOpIJ04S2KMVDO+SEidsXRH/XHDpbzXykKcMTLdTXH6cQ==", "integrity": "sha512-bW9IpSAKYvkqDGRZzayBXIgPsj2xmmVHLJ+flGSoN0fF98pGoKFhbunIol0VF2Crka7z984EEhFi623Rl7e6gg==",
"dev": true, "dev": true,
"requires": { "requires": {
"@types/json-schema": "^7.0.3", "@types/json-schema": "^7.0.3",
"@typescript-eslint/scope-manager": "4.4.1", "@typescript-eslint/scope-manager": "4.5.0",
"@typescript-eslint/types": "4.4.1", "@typescript-eslint/types": "4.5.0",
"@typescript-eslint/typescript-estree": "4.4.1", "@typescript-eslint/typescript-estree": "4.5.0",
"eslint-scope": "^5.0.0", "eslint-scope": "^5.0.0",
"eslint-utils": "^2.0.0" "eslint-utils": "^2.0.0"
} }
}, },
"@typescript-eslint/parser": { "@typescript-eslint/parser": {
"version": "4.4.1", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.4.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.5.0.tgz",
"integrity": "sha512-S0fuX5lDku28Au9REYUsV+hdJpW/rNW0gWlc4SXzF/kdrRaAVX9YCxKpziH7djeWT/HFAjLZcnY7NJD8xTeUEg==", "integrity": "sha512-xb+gmyhQcnDWe+5+xxaQk5iCw6KqXd8VQxGiTeELTMoYeRjpocZYYRP1gFVM2C8Yl0SpUvLa1lhprwqZ00w3Iw==",
"dev": true, "dev": true,
"requires": { "requires": {
"@typescript-eslint/scope-manager": "4.4.1", "@typescript-eslint/scope-manager": "4.5.0",
"@typescript-eslint/types": "4.4.1", "@typescript-eslint/types": "4.5.0",
"@typescript-eslint/typescript-estree": "4.4.1", "@typescript-eslint/typescript-estree": "4.5.0",
"debug": "^4.1.1" "debug": "^4.1.1"
} }
}, },
"@typescript-eslint/scope-manager": { "@typescript-eslint/scope-manager": {
"version": "4.4.1", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.4.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.5.0.tgz",
"integrity": "sha512-2oD/ZqD4Gj41UdFeWZxegH3cVEEH/Z6Bhr/XvwTtGv66737XkR4C9IqEkebCuqArqBJQSj4AgNHHiN1okzD/wQ==", "integrity": "sha512-C0cEO0cTMPJ/w4RA/KVe4LFFkkSh9VHoFzKmyaaDWAnPYIEzVCtJ+Un8GZoJhcvq+mPFXEsXa01lcZDHDG6Www==",
"dev": true, "dev": true,
"requires": { "requires": {
"@typescript-eslint/types": "4.4.1", "@typescript-eslint/types": "4.5.0",
"@typescript-eslint/visitor-keys": "4.4.1" "@typescript-eslint/visitor-keys": "4.5.0"
} }
}, },
"@typescript-eslint/types": { "@typescript-eslint/types": {
"version": "4.4.1", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.4.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.5.0.tgz",
"integrity": "sha512-KNDfH2bCyax5db+KKIZT4rfA8rEk5N0EJ8P0T5AJjo5xrV26UAzaiqoJCxeaibqc0c/IvZxp7v2g3difn2Pn3w==", "integrity": "sha512-n2uQoXnyWNk0Les9MtF0gCK3JiWd987JQi97dMSxBOzVoLZXCNtxFckVqt1h8xuI1ix01t+iMY4h4rFMj/303g==",
"dev": true "dev": true
}, },
"@typescript-eslint/typescript-estree": { "@typescript-eslint/typescript-estree": {
"version": "4.4.1", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.4.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.5.0.tgz",
"integrity": "sha512-wP/V7ScKzgSdtcY1a0pZYBoCxrCstLrgRQ2O9MmCUZDtmgxCO/TCqOTGRVwpP4/2hVfqMz/Vw1ZYrG8cVxvN3g==", "integrity": "sha512-gN1mffq3zwRAjlYWzb5DanarOPdajQwx5MEWkWCk0XvqC8JpafDTeioDoow2L4CA/RkYZu7xEsGZRhqrTsAG8w==",
"dev": true, "dev": true,
"requires": { "requires": {
"@typescript-eslint/types": "4.4.1", "@typescript-eslint/types": "4.5.0",
"@typescript-eslint/visitor-keys": "4.4.1", "@typescript-eslint/visitor-keys": "4.5.0",
"debug": "^4.1.1", "debug": "^4.1.1",
"globby": "^11.0.1", "globby": "^11.0.1",
"is-glob": "^4.0.1", "is-glob": "^4.0.1",
@ -3222,12 +3228,12 @@
} }
}, },
"@typescript-eslint/visitor-keys": { "@typescript-eslint/visitor-keys": {
"version": "4.4.1", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.4.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.5.0.tgz",
"integrity": "sha512-H2JMWhLaJNeaylSnMSQFEhT/S/FsJbebQALmoJxMPMxLtlVAMy2uJP/Z543n9IizhjRayLSqoInehCeNW9rWcw==", "integrity": "sha512-UHq4FSa55NDZqscRU//O5ROFhHa9Hqn9KWTEvJGTArtTQp5GKv9Zqf6d/Q3YXXcFv4woyBml7fJQlQ+OuqRcHA==",
"dev": true, "dev": true,
"requires": { "requires": {
"@typescript-eslint/types": "4.4.1", "@typescript-eslint/types": "4.5.0",
"eslint-visitor-keys": "^2.0.0" "eslint-visitor-keys": "^2.0.0"
} }
}, },
@ -5909,9 +5915,9 @@
} }
}, },
"eslint-config-prettier": { "eslint-config-prettier": {
"version": "6.13.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.13.0.tgz", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.14.0.tgz",
"integrity": "sha512-LcT0i0LSmnzqK2t764pyIt7kKH2AuuqKRTtJTdddWxOiUja9HdG5GXBVF2gmCTvVYWVsTu8J2MhJLVGRh+pj8w==", "integrity": "sha512-DbVwh0qZhAC7CNDWcq8cBdK6FcVHiMTKmCypOPWeZkp9hJ8xYwTaWSa6bb6cjfi8KOeJy0e9a8Izxyx+O4+gCQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"get-stdin": "^6.0.0" "get-stdin": "^6.0.0"
@ -6488,6 +6494,11 @@
"write": "1.0.3" "write": "1.0.3"
} }
}, },
"flatpickr": {
"version": "4.6.6",
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.6.tgz",
"integrity": "sha512-EZ48CJMttMg3maMhJoX+GvTuuEhX/RbA1YeuI19attP3pwBdbYy6+yqAEVm0o0hSBFYBiLbVxscLW6gJXq6H3A=="
},
"flatted": { "flatted": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz",
@ -7888,9 +7899,9 @@
} }
}, },
"lint-staged": { "lint-staged": {
"version": "10.4.1", "version": "10.4.2",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.4.1.tgz", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.4.2.tgz",
"integrity": "sha512-E2Y6Mu1haUD3ZefzwBG8tqy3QDQ9udWRS946YcuDCU8Mi22RjwxrEhLrqTLszxl80DG/sCtKdGCArzEkTsBzJQ==", "integrity": "sha512-OLCA9K1hS+Sl179SO6kX0JtnsaKj/MZalEhUj5yAgXsb63qPI/Gfn6Ua1KuZdbfkZNEu3/n5C/obYCu70IMt9g==",
"dev": true, "dev": true,
"requires": { "requires": {
"chalk": "^4.1.0", "chalk": "^4.1.0",
@ -8612,13 +8623,13 @@
"integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=" "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4="
}, },
"mem": { "mem": {
"version": "6.1.1", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/mem/-/mem-6.1.1.tgz", "resolved": "https://registry.npmjs.org/mem/-/mem-8.0.0.tgz",
"integrity": "sha512-Ci6bIfq/UgcxPTYa8dQQ5FY3BzKkT894bwXWXxC/zqs0XgMO2cT20CGkOqda7gZNkmK5VP4x89IGZ6K7hfbn3Q==", "integrity": "sha512-qrcJOe6uD+EW8Wrci1Vdiua/15Xw3n/QnaNXE7varnB6InxSk7nu3/i5jfy3S6kWxr8WYJ6R1o0afMUtvorTsA==",
"dev": true, "dev": true,
"requires": { "requires": {
"map-age-cleaner": "^0.1.3", "map-age-cleaner": "^0.1.3",
"mimic-fn": "^3.0.0" "mimic-fn": "^3.1.0"
} }
}, },
"meow": { "meow": {
@ -15403,9 +15414,9 @@
} }
}, },
"rollup": { "rollup": {
"version": "2.31.0", "version": "2.32.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.31.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.32.1.tgz",
"integrity": "sha512-0d8S3XwEZ7aCP910/9SjnelgLvC+ZXziouVolzxPOM1zvKkHioGkWGJIWmlOULlmvB8BZ6S0wrgsT4yMz0eyMg==", "integrity": "sha512-Op2vWTpvK7t6/Qnm1TTh7VjEZZkN8RWgf0DHbkKzQBwNf748YhXbozHVefqpPp/Fuyk/PQPAnYsBxAEtlMvpUw==",
"dev": true, "dev": true,
"requires": { "requires": {
"fsevents": "~2.1.2" "fsevents": "~2.1.2"
@ -17027,9 +17038,9 @@
} }
}, },
"tailwindcss": { "tailwindcss": {
"version": "1.9.2", "version": "1.9.5",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-1.9.2.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-1.9.5.tgz",
"integrity": "sha512-D3uKSZZkh4GaKiZWmPEfNrqEmEuYdwaqXOQ7trYSQQFI5laSD9+b2FUUj5g39nk5R1omKp5tBW9wZsfJq+KIVA==", "integrity": "sha512-Je5t1fAfyW333YTpSxF+8uJwbnrkpyBskDtZYgSMMKQbNp6QUhEKJ4g/JIevZjD2Zidz9VxLraEUq/yWOx6nQg==",
"dev": true, "dev": true,
"requires": { "requires": {
"@fullhuman/postcss-purgecss": "^2.1.2", "@fullhuman/postcss-purgecss": "^2.1.2",

View File

@ -25,23 +25,24 @@
"release": "semantic-release" "release": "semantic-release"
}, },
"dependencies": { "dependencies": {
"@amcharts/amcharts4": "^4.10.7", "@amcharts/amcharts4": "^4.10.8",
"@amcharts/amcharts4-geodata": "^4.1.17", "@amcharts/amcharts4-geodata": "^4.1.17",
"@popperjs/core": "^2.5.3", "@popperjs/core": "^2.5.3",
"choices.js": "^9.0.1", "choices.js": "^9.0.1",
"flatpickr": "^4.6.6",
"prosemirror-example-setup": "^1.1.2", "prosemirror-example-setup": "^1.1.2",
"prosemirror-markdown": "^1.5.0", "prosemirror-markdown": "^1.5.0",
"prosemirror-state": "^1.3.3", "prosemirror-state": "^1.3.3",
"prosemirror-view": "^1.16.0" "prosemirror-view": "^1.16.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.12.1", "@babel/core": "^7.12.3",
"@babel/plugin-proposal-class-properties": "^7.12.1", "@babel/plugin-proposal-class-properties": "^7.12.1",
"@babel/preset-env": "^7.12.1", "@babel/preset-env": "^7.12.1",
"@babel/preset-typescript": "^7.12.1", "@babel/preset-typescript": "^7.12.1",
"@commitlint/cli": "^11.0.0", "@commitlint/cli": "^11.0.0",
"@commitlint/config-conventional": "^11.0.0", "@commitlint/config-conventional": "^11.0.0",
"@prettier/plugin-php": "^0.15.0", "@prettier/plugin-php": "^0.15.1",
"@rollup/plugin-babel": "^5.2.1", "@rollup/plugin-babel": "^5.2.1",
"@rollup/plugin-commonjs": "^15.1.0", "@rollup/plugin-commonjs": "^15.1.0",
"@rollup/plugin-json": "^4.1.0", "@rollup/plugin-json": "^4.1.0",
@ -55,22 +56,22 @@
"@tailwindcss/typography": "^0.2.0", "@tailwindcss/typography": "^0.2.0",
"@types/prosemirror-markdown": "^1.0.3", "@types/prosemirror-markdown": "^1.0.3",
"@types/prosemirror-view": "^1.16.1", "@types/prosemirror-view": "^1.16.1",
"@typescript-eslint/eslint-plugin": "^4.4.1", "@typescript-eslint/eslint-plugin": "^4.5.0",
"@typescript-eslint/parser": "^4.4.1", "@typescript-eslint/parser": "^4.5.0",
"cross-env": "^7.0.2", "cross-env": "^7.0.2",
"cssnano": "^4.1.10", "cssnano": "^4.1.10",
"cz-conventional-changelog": "^3.3.0", "cz-conventional-changelog": "^3.3.0",
"eslint": "^7.11.0", "eslint": "^7.11.0",
"eslint-config-prettier": "^6.13.0", "eslint-config-prettier": "^6.14.0",
"eslint-plugin-prettier": "^3.1.4", "eslint-plugin-prettier": "^3.1.4",
"husky": "^4.3.0", "husky": "^4.3.0",
"lint-staged": "^10.4.1", "lint-staged": "^10.4.2",
"postcss-cli": "^8.1.0", "postcss-cli": "^8.1.0",
"postcss-import": "^12.0.1", "postcss-import": "^12.0.1",
"postcss-preset-env": "^6.7.0", "postcss-preset-env": "^6.7.0",
"prettier": "2.1.2", "prettier": "2.1.2",
"prettier-plugin-organize-imports": "^1.1.1", "prettier-plugin-organize-imports": "^1.1.1",
"rollup": "^2.31.0", "rollup": "^2.32.1",
"rollup-plugin-multi-input": "^1.1.1", "rollup-plugin-multi-input": "^1.1.1",
"rollup-plugin-node-polyfills": "^0.2.1", "rollup-plugin-node-polyfills": "^0.2.1",
"rollup-plugin-postcss": "^3.1.8", "rollup-plugin-postcss": "^3.1.8",
@ -79,7 +80,7 @@
"stylelint": "^13.7.2", "stylelint": "^13.7.2",
"stylelint-config-standard": "^20.0.0", "stylelint-config-standard": "^20.0.0",
"svgo": "^1.3.2", "svgo": "^1.3.2",
"tailwindcss": "^1.9.2", "tailwindcss": "^1.9.5",
"typescript": "^4.0.3" "typescript": "^4.0.3"
}, },
"husky": { "husky": {

View File

@ -1,9 +1,9 @@
# Running Application Tests # Running Application Tests
This is the quick-start to CodeIgniter testing. Its intent is to describe what This is the quick-start to CodeIgniter testing. Its intent is to describe what
it takes to set up your application and get it ready to run unit tests. it takes to set up your application and get it ready to run unit tests. It is
It is not intended to be a full description of the test features that you can not intended to be a full description of the test features that you can use to
use to test your application. Those details can be found in the documentation. test your application. Those details can be found in the documentation.
## Resources ## Resources
@ -15,33 +15,36 @@ use to test your application. Those details can be found in the documentation.
It is recommended to use the latest version of PHPUnit. At the time of this It is recommended to use the latest version of PHPUnit. At the time of this
writing we are running version 8.5.2. Support for this has been built into the writing we are running version 8.5.2. Support for this has been built into the
**composer.json** file that ships with CodeIgniter and can easily be installed **composer.json** file that ships with CodeIgniter and can easily be installed
via [Composer](https://getcomposer.org/) if you don't already have it installed globally. via [Composer](https://getcomposer.org/) if you don't already have it installed
globally.
> composer install > composer install
If running under OS X or Linux, you can create a symbolic link to make running tests a touch nicer. If running under OS X or Linux, you can create a symbolic link to make running
tests a touch nicer.
> ln -s ./vendor/bin/phpunit ./phpunit > ln -s ./vendor/bin/phpunit ./phpunit
You also need to install [XDebug](https://xdebug.org/index.php) in order You also need to install [XDebug](https://xdebug.org/index.php) in order for
for code coverage to be calculated successfully. code coverage to be calculated successfully.
## Setting Up ## Setting Up
A number of the tests use a running database. A number of the tests use a running database. In order to set up the database
In order to set up the database edit the details for the `tests` group in edit the details for the `tests` group in **app/Config/Database.php** or
**app/Config/Database.php** or **phpunit.xml**. Make sure that you provide a database engine **phpunit.xml**. Make sure that you provide a database engine that is currently
that is currently running on your machine. More details on a test database setup are in the running on your machine. More details on a test database setup are in the
_Docs>>Testing>>Testing Your Database_ section of the documentation. _Docs>>Testing>>Testing Your Database_ section of the documentation.
If you want to run the tests without using live database you can If you want to run the tests without using live database you can exclude
exclude @DatabaseLive group. Or make a copy of **phpunit.dist.xml** - @DatabaseLive group. Or make a copy of **phpunit.dist.xml** - call it
call it **phpunit.xml** - and comment out the <testsuite> named "database". This will make **phpunit.xml** - and comment out the <testsuite> named "database". This will
the tests run quite a bit faster. make the tests run quite a bit faster.
## Running the tests ## Running the tests
The entire test suite can be run by simply typing one command-line command from the main directory. The entire test suite can be run by simply typing one command-line command from
the main directory.
> ./phpunit > ./phpunit
@ -52,59 +55,62 @@ directory name after phpunit.
## Generating Code Coverage ## Generating Code Coverage
To generate coverage information, including HTML reports you can view in your browser, To generate coverage information, including HTML reports you can view in your
you can use the following command: browser, you can use the following command:
> ./phpunit --colors --coverage-text=tests/coverage.txt --coverage-html=tests/coverage/ -d memory_limit=1024m > ./phpunit --colors --coverage-text=tests/coverage.txt --coverage-html=tests/coverage/ -d memory_limit=1024m
This runs all of the tests again collecting information about how many lines, This runs all of the tests again collecting information about how many lines,
functions, and files are tested. It also reports the percentage of the code that is covered by tests. functions, and files are tested. It also reports the percentage of the code that
It is collected in two formats: a simple text file that provides an overview as well is covered by tests. It is collected in two formats: a simple text file that
as a comprehensive collection of HTML files that show the status of every line of code in the project. provides an overview as well as a comprehensive collection of HTML files that
show the status of every line of code in the project.
The text file can be found at **tests/coverage.txt**. The text file can be found at **tests/coverage.txt**. The HTML files can be
The HTML files can be viewed by opening **tests/coverage/index.html** in your favorite browser. viewed by opening **tests/coverage/index.html** in your favorite browser.
## PHPUnit XML Configuration ## PHPUnit XML Configuration
The repository has a `phpunit.xml.dist` file in the project root that's used for The repository has a `phpunit.xml.dist` file in the project root that's used for
PHPUnit configuration. This is used to provide a default configuration if you PHPUnit configuration. This is used to provide a default configuration if you do
do not have your own configuration file in the project root. not have your own configuration file in the project root.
The normal practice would be to copy `phpunit.xml.dist` to `phpunit.xml` The normal practice would be to copy `phpunit.xml.dist` to `phpunit.xml` (which
(which is git ignored), and to tailor it as you see fit. is git ignored), and to tailor it as you see fit. For instance, you might wish
For instance, you might wish to exclude database tests, or automatically generate to exclude database tests, or automatically generate HTML code coverage reports.
HTML code coverage reports.
## Test Cases ## Test Cases
Every test needs a _test case_, or class that your tests extend. CodeIgniter 4 Every test needs a _test case_, or class that your tests extend. CodeIgniter 4
provides a few that you may use directly: provides a few that you may use directly:
- `CodeIgniter\Test\CIUnitTestCase` - for basic tests with no other service needs - `CodeIgniter\Test\CIUnitTestCase` - for basic tests with no other service
needs
- `CodeIgniter\Test\CIDatabaseTestCase` - for tests that need database access - `CodeIgniter\Test\CIDatabaseTestCase` - for tests that need database access
Most of the time you will want to write your own test cases to hold functions and services Most of the time you will want to write your own test cases to hold functions
common to your test suites. and services common to your test suites.
## Creating Tests ## Creating Tests
All tests go in the **tests/** directory. Each test file is a class that extends a All tests go in the **tests/** directory. Each test file is a class that extends
**Test Case** (see above) and contains methods for the individual tests. These method a **Test Case** (see above) and contains methods for the individual tests. These
names must start with the word "test" and should have descriptive names for precisely what method names must start with the word "test" and should have descriptive names
they are testing: for precisely what they are testing: `testUserCanModifyFile()`
`testUserCanModifyFile()` `testOutputColorMatchesInput()` `testIsLoggedInFailsWithInvalidUser()` `testOutputColorMatchesInput()` `testIsLoggedInFailsWithInvalidUser()`
Writing tests is an art, and there are many resources available to help learn how. Writing tests is an art, and there are many resources available to help learn
Review the links above and always pay attention to your code coverage. how. Review the links above and always pay attention to your code coverage.
### Database Tests ### Database Tests
Tests can include migrating, seeding, and testing against a mock or live<sup>1</sup> database. Tests can include migrating, seeding, and testing against a mock or
Be sure to modify the test case (or create your own) to point to your seed and migrations live<sup>1</sup> database. Be sure to modify the test case (or create your own)
and include any additional steps to be run before tests in the `setUp()` method. to point to your seed and migrations and include any additional steps to be run
before tests in the `setUp()` method.
<sup>1</sup> Note: If you are using database tests that require a live database connection <sup>1</sup> Note: If you are using database tests that require a live database
you will need to rename **phpunit.xml.dist** to **phpunit.xml**, uncomment the database connection you will need to rename **phpunit.xml.dist** to **phpunit.xml**,
configuration lines and add your connection details. Prevent **phpunit.xml** from being uncomment the database configuration lines and add your connection details.
tracked in your repo by adding it to **.gitignore**. Prevent **phpunit.xml** from being tracked in your repo by adding it to
**.gitignore**.

View File

@ -21,5 +21,6 @@
/* Advanced Options */ /* Advanced Options */
"skipLibCheck": true /* Skip type checking of declaration files. */, "skipLibCheck": true /* Skip type checking of declaration files. */,
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
} },
"include": ["app/Views/_assets/**/*"]
} }