Make dashboard paginated, searchable and sortable.

This commit is contained in:
Buster Neece 2024-01-15 17:07:28 -06:00
parent 1ae1a16b5e
commit 84378369c7
No known key found for this signature in database
6 changed files with 246 additions and 184 deletions

View File

@ -184,7 +184,7 @@
</tr>
</thead>
<tbody>
<template v-if="isLoading">
<template v-if="isLoading && hideOnLoading">
<tr>
<td
:colspan="columnCount"
@ -323,6 +323,10 @@ const props = defineProps({
type: Boolean,
default: false
},
hideOnLoading: {
type: Boolean,
default: true
},
showToolbar: {
type: Boolean,
default: true

View File

@ -143,126 +143,98 @@
</div>
</template>
<loading :loading="stationsLoading">
<div class="table-responsive">
<table
id="station_dashboard"
class="table table-striped"
>
<colgroup>
<col width="5%">
<col width="30%">
<col width="10%">
<col width="40%">
<col width="15%">
</colgroup>
<thead>
<tr>
<th class="pe-3">
&nbsp;
</th>
<th class="ps-2">
{{ $gettext('Station Name') }}
</th>
<th class="text-center">
{{ $gettext('Listeners') }}
</th>
<th>{{ $gettext('Now Playing') }}</th>
<th class="text-end" />
</tr>
</thead>
<tbody>
<tr
v-for="item in stations"
:key="item.station.id"
class="align-middle"
>
<td class="text-center pe-1">
<play-button
class="file-icon btn-lg"
:url="item.station.listen_url"
is-stream
/>
</td>
<td class="ps-2">
<div class="h5 m-0">
{{ item.station.name }}
</div>
<div v-if="item.station.is_public">
<a
:href="item.links.public"
target="_blank"
>
{{ $gettext('Public Page') }}
</a>
</div>
</td>
<td class="text-center">
<span class="pe-1">
<icon
class="sm align-middle"
:icon="IconHeadphones"
/>
</span>
<template v-if="item.links.listeners">
<a
:href="item.links.listeners"
:aria-label="$gettext('View Listener Report')"
>
{{ item.listeners.total }}
</a>
</template>
<template v-else>
{{ item.listeners.total }}
</template>
</td>
<td>
<div class="d-flex align-items-center">
<album-art
v-if="showAlbumArt"
:src="item.now_playing.song.art"
class="flex-shrink-0 pe-3"
/>
<data-table
id="dashboard_stations"
ref="$datatable"
:fields="stationFields"
:api-url="stationsUrl"
paginated
responsive
show-toolbar
:hide-on-loading="false"
>
<template #cell(play_button)="{ item }">
<play-button
class="file-icon btn-lg"
:url="item.station.listen_url"
is-stream
/>
</template>
<template #cell(name)="{ item }">
<div class="h5 m-0">
{{ item.station.name }}
</div>
<div v-if="item.station.is_public">
<a
:href="item.links.public"
target="_blank"
>
{{ $gettext('Public Page') }}
</a>
</div>
</template>
<template #cell(listeners)="{ item }">
<span class="pe-1">
<icon
class="sm align-middle"
:icon="IconHeadphones"
/>
</span>
<template v-if="item.links.listeners">
<a
:href="item.links.listeners"
:aria-label="$gettext('View Listener Report')"
>
{{ item.listeners.total }}
</a>
</template>
<template v-else>
{{ item.listeners.total }}
</template>
</template>
<template #cell(now_playing)="{ item }">
<div class="d-flex align-items-center">
<album-art
v-if="showAlbumArt"
:src="item.now_playing.song.art"
class="flex-shrink-0 pe-3"
/>
<div
v-if="!item.is_online"
class="flex-fill text-muted"
>
{{ $gettext('Station Offline') }}
</div>
<div
v-else-if="item.now_playing.song.title !== ''"
class="flex-fill"
>
<strong><span class="nowplaying-title">
{{ item.now_playing.song.title }}
</span></strong><br>
<span class="nowplaying-artist">{{ item.now_playing.song.artist }}</span>
</div>
<div
v-else
class="flex-fill"
>
<strong><span class="nowplaying-title">
{{ item.now_playing.song.text }}
</span></strong>
</div>
</div>
</td>
<td class="text-end">
<a
class="btn btn-primary"
:href="item.links.manage"
role="button"
>
{{ $gettext('Manage') }}
</a>
</td>
</tr>
</tbody>
</table>
</div>
</loading>
<div
v-if="!item.is_online"
class="flex-fill text-muted"
>
{{ $gettext('Station Offline') }}
</div>
<div
v-else-if="item.now_playing.song.title !== ''"
class="flex-fill"
>
<strong><span class="nowplaying-title">
{{ item.now_playing.song.title }}
</span></strong><br>
<span class="nowplaying-artist">{{ item.now_playing.song.artist }}</span>
</div>
<div
v-else
class="flex-fill"
>
<strong><span class="nowplaying-title">
{{ item.now_playing.song.text }}
</span></strong>
</div>
</div>
</template>
<template #cell(actions)="{ item }">
<a
class="btn btn-primary"
:href="item.links.manage"
role="button"
>
{{ $gettext('Manage') }}
</a>
</template>
</data-table>
</card-page>
</div>
@ -276,11 +248,10 @@ import Icon from '~/components/Common/Icon.vue';
import PlayButton from "~/components/Common/PlayButton.vue";
import AlbumArt from "~/components/Common/AlbumArt.vue";
import {useAxios} from "~/vendor/axios";
import {useAsyncState} from "@vueuse/core";
import {useAsyncState, useIntervalFn} from "@vueuse/core";
import {computed, ref} from "vue";
import DashboardCharts from "~/components/DashboardCharts.vue";
import {useTranslate} from "~/vendor/gettext";
import Loading from "~/components/Common/Loading.vue";
import Lightbox from "~/components/Common/Lightbox.vue";
import CardPage from "~/components/Common/CardPage.vue";
import HeaderInlinePlayer from "~/components/HeaderInlinePlayer.vue";
@ -289,7 +260,8 @@ import useOptionalStorage from "~/functions/useOptionalStorage";
import {IconAccountCircle, IconHeadphones, IconInfo, IconSettings, IconWarning} from "~/components/Common/icons";
import UserInfoPanel from "~/components/Account/UserInfoPanel.vue";
import {getApiUrl} from "~/router.ts";
import useAutoRefreshingAsyncState from "~/functions/useAutoRefreshingAsyncState.ts";
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
const props = defineProps({
profileUrl: {
@ -332,19 +304,47 @@ const langShowHideCharts = computed(() => {
: $gettext('Show Charts')
});
const {axios, axiosSilent} = useAxios();
const {axios} = useAxios();
const {state: notifications, isLoading: notificationsLoading} = useAsyncState(
() => axios.get(notificationsUrl.value).then((r) => r.data),
[]
);
const {state: stations, isLoading: stationsLoading} = useAutoRefreshingAsyncState(
() => axiosSilent.get(stationsUrl.value).then((r) => r.data),
[],
const stationFields: DataTableField[] = [
{
timeout: 15000
key: 'play_button',
sortable: false,
class: 'shrink'
},
{
key: 'name',
label: $gettext('Station Name'),
sortable: true,
},
{
key: 'listeners',
label: $gettext('Listeners'),
sortable: true
},
{
key: 'now_playing',
label: $gettext('Now Playing'),
sortable: true
},
{
key: 'actions',
sortable: false,
class: 'shrink'
}
];
const $datatable = ref<DataTableTemplateRef>(null);
const {refresh} = useHasDatatable($datatable);
useIntervalFn(
refresh,
computed(() => (document.hidden) ? 30000 : 15000)
);
const $lightbox = ref<LightboxTemplateRef>(null);

View File

@ -10,6 +10,7 @@ use App\Controller\Api\Traits\CanSearchResults;
use App\Controller\Api\Traits\CanSortResults;
use App\Controller\SingleActionInterface;
use App\Entity\Api\Dashboard;
use App\Entity\Api\NowPlaying\NowPlaying;
use App\Entity\ApiGenerator\NowPlayingApiGenerator;
use App\Entity\Station;
use App\Enums\StationPermissions;
@ -35,63 +36,72 @@ final class StationsAction implements SingleActionInterface
Response $response,
array $params
): ResponseInterface {
$router = $request->getRouter();
$acl = $request->getAcl();
/** @var Station[] $stations */
$stations = array_filter(
$this->em->getRepository(Station::class)->findAll(),
static function ($station) use ($acl) {
/** @var Station $station */
return $station->getIsEnabled() &&
$acl->isAllowed(StationPermissions::View, $station->getId());
$this->em->getRepository(Station::class)->findBy([
'is_enabled' => 1,
]),
static function (Station $station) use ($acl) {
return $acl->isAllowed(StationPermissions::View, $station->getId());
}
);
$listenersEnabled = $this->readSettings()->isAnalyticsEnabled();
/** @var NowPlaying[] $viewStations */
$viewStations = array_map(
fn(Station $station) => $this->npApiGenerator->currentOrEmpty($station),
$stations
);
$viewStations = [];
foreach ($stations as $station) {
$np = $this->npApiGenerator->currentOrEmpty($station);
$np->resolveUrls($request->getRouter()->getBaseUrl());
$row = new Dashboard();
$row->fromParentObject($np);
$row->links = [
'public' => $router->named('public:index', ['station_id' => $station->getShortName()]),
'manage' => $router->named('stations:index:index', ['station_id' => $station->getId()]),
];
if ($listenersEnabled && $acl->isAllowed(StationPermissions::Reports, $station->getId())) {
$row->links['listeners'] = $router->named(
'stations:reports:listeners',
['station_id' => $station->getId()]
);
}
$viewStations[] = $row;
}
$searchPhrase = $this->getSearchPhrase($request);
if (null !== $searchPhrase) {
$viewStations = array_filter(
$viewStations,
static function (Dashboard $row) use ($searchPhrase) {
return false !== mb_stripos($row->station->name, $searchPhrase);
}
);
}
$viewStations = $this->searchArray(
$request,
$viewStations,
[
'station.name',
]
);
$viewStations = $this->sortArray(
$request,
$viewStations,
[
'name' => 'station.name',
'listeners' => 'listeners.current',
'now_playing' => 'is_online',
],
'station.name'
);
return Paginator::fromArray($viewStations, $request)->write($response);
$paginator = Paginator::fromArray($viewStations, $request);
$router = $request->getRouter();
$baseUrl = $router->getBaseUrl();
$listenersEnabled = $this->readSettings()->isAnalyticsEnabled();
$paginator->setPostprocessor(
function (NowPlaying $np) use ($router, $baseUrl, $listenersEnabled, $acl) {
$np->resolveUrls($baseUrl);
$row = new Dashboard();
$row->fromParentObject($np);
$row->links = [
'public' => $router->named('public:index', ['station_id' => $np->station->shortcode]),
'manage' => $router->named('stations:index:index', ['station_id' => $np->station->id]),
];
if ($listenersEnabled && $acl->isAllowed(StationPermissions::Reports, $np->station->id)) {
$row->links['listeners'] = $router->named(
'stations:reports:listeners',
['station_id' => $np->station->id]
);
}
return $row;
}
);
return $paginator->write($response);
}
}

View File

@ -10,6 +10,8 @@ use Doctrine\ORM\QueryBuilder;
trait CanSearchResults
{
use UsesPropertyAccessor;
/**
* @param string[] $fieldsToSearch
*/
@ -34,6 +36,40 @@ trait CanSearchResults
)->setParameter('search', '%' . $searchPhrase . '%');
}
/**
* @param string[] $fieldsToSearch
*/
protected function searchArray(
ServerRequest $request,
array $results,
array $fieldsToSearch,
string $searchParam = 'searchPhrase'
): array {
$searchPhrase = $this->getSearchPhrase($request, $searchParam);
if (null === $searchPhrase) {
return $results;
}
$propertyAccessor = self::getPropertyAccessor();
return array_filter(
$results,
function (mixed $result) use ($propertyAccessor, $searchPhrase, $fieldsToSearch): bool {
foreach ($fieldsToSearch as $field) {
$fieldVal = Types::string(
$propertyAccessor->getValue($result, $field)
);
if (false !== mb_stripos($fieldVal, $searchPhrase)) {
return true;
}
}
return false;
}
);
}
protected function getSearchPhrase(
ServerRequest $request,
string $searchParam = 'searchPhrase'

View File

@ -8,11 +8,12 @@ use App\Http\ServerRequest;
use App\Utilities\Types;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
trait CanSortResults
{
use UsesPropertyAccessor;
protected function sortQueryBuilder(
ServerRequest $request,
QueryBuilder $queryBuilder,
@ -97,19 +98,4 @@ trait CanSortResults
? $aVal <=> $bVal
: $bVal <=> $aVal;
}
protected static ?PropertyAccessorInterface $propertyAccessor = null;
protected static function getPropertyAccessor(): PropertyAccessorInterface
{
if (null === self::$propertyAccessor) {
self::$propertyAccessor = PropertyAccess::createPropertyAccessorBuilder()
->disableExceptionOnInvalidIndex()
->disableExceptionOnInvalidPropertyPath()
->disableMagicMethods()
->getPropertyAccessor();
}
return self::$propertyAccessor;
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Traits;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
trait UsesPropertyAccessor
{
protected static ?PropertyAccessorInterface $propertyAccessor = null;
protected static function getPropertyAccessor(): PropertyAccessorInterface
{
if (null === self::$propertyAccessor) {
self::$propertyAccessor = PropertyAccess::createPropertyAccessorBuilder()
->disableExceptionOnInvalidIndex()
->disableExceptionOnInvalidPropertyPath()
->disableMagicMethods()
->getPropertyAccessor();
}
return self::$propertyAccessor;
}
}