mirror of https://github.com/nextcloud/calendar
Augment the category menu by system tags and already used categories.
This commit add all available "collaborative tags" and all already used categories into option groups of the tags-menu of the side-bar editor. This commit addresses and is a related to a couple of open issues: nextcloud/calendar#3735 Calendar Categories: Propose Categories already used - this should be fixed by this commit nextcloud/calendar#1644 Add own categories, delete default ones - this is partly fixed in the sense that collaboritive tags are now also proposed as calendar categories. - still default categories cannot be deleted - however, using option groups one at least has some sort of overview about the origin of the proposed category nextcloud/server#29950 Save VEVENT CATEGORIES as vcategory - this issue is totally "ignored" by this commit as the proposed solution there is not needed (the categories are already there in the oc_calendarobject_props table) - that would have to be discussed there: but my impression that the tables and classed mentioned there are obsolete and no longer used. Co-authored-by: Anna <anna@nextcloud.com> Signed-off-by: Claus-Justus Heine <himself@claus-justus-heine.de>
This commit is contained in:
parent
8da83332bd
commit
b3af4c3507
|
@ -25,6 +25,7 @@ declare(strict_types=1);
|
|||
namespace OCA\Calendar\Controller;
|
||||
|
||||
use OCA\Calendar\Service\Appointments\AppointmentConfigService;
|
||||
use OCA\Calendar\Service\CategoriesService;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http\FileDisplayResponse;
|
||||
|
@ -44,6 +45,9 @@ class ViewController extends Controller {
|
|||
/** @var AppointmentConfigService */
|
||||
private $appointmentConfigService;
|
||||
|
||||
/** @var CategoriesService */
|
||||
private $categoriesService;
|
||||
|
||||
/** @var IInitialState */
|
||||
private $initialStateService;
|
||||
|
||||
|
@ -59,6 +63,7 @@ class ViewController extends Controller {
|
|||
IRequest $request,
|
||||
IConfig $config,
|
||||
AppointmentConfigService $appointmentConfigService,
|
||||
CategoriesService $categoriesService,
|
||||
IInitialState $initialStateService,
|
||||
IAppManager $appManager,
|
||||
?string $userId,
|
||||
|
@ -66,6 +71,7 @@ class ViewController extends Controller {
|
|||
parent::__construct($appName, $request);
|
||||
$this->config = $config;
|
||||
$this->appointmentConfigService = $appointmentConfigService;
|
||||
$this->categoriesService = $categoriesService;
|
||||
$this->initialStateService = $initialStateService;
|
||||
$this->appManager = $appManager;
|
||||
$this->userId = $userId;
|
||||
|
@ -135,6 +141,7 @@ class ViewController extends Controller {
|
|||
$this->initialStateService->provideInitialState('appointmentConfigs', $this->appointmentConfigService->getAllAppointmentConfigurations($this->userId));
|
||||
$this->initialStateService->provideInitialState('disable_appointments', $disableAppointments);
|
||||
$this->initialStateService->provideInitialState('can_subscribe_link', $canSubscribeLink);
|
||||
$this->initialStateService->provideInitialState('categories', $this->categoriesService->getCategories($this->userId));
|
||||
|
||||
return new TemplateResponse($this->appName, 'main');
|
||||
}
|
||||
|
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Calendar App
|
||||
*
|
||||
* @copyright 2023 Claus-Justus Heine <himself@claus-justus-heine.de>
|
||||
*
|
||||
* @author Claus-Justus Heine <himself@claus-justus-heine.de>
|
||||
*
|
||||
* This library is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
* License as published by the Free Software Foundation; either
|
||||
* version 3 of the License, or any later version.
|
||||
*
|
||||
* This library is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU AFFERO GENERAL PUBLIC LICENSE for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public
|
||||
* License along with this library. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\Calendar\Service;
|
||||
|
||||
use OCP\Calendar\ICalendarQuery;
|
||||
use OCP\Calendar\IManager as ICalendarManager;
|
||||
use OCP\IL10N;
|
||||
use OCP\SystemTag\ISystemTag;
|
||||
use OCP\SystemTag\ISystemTagManager;
|
||||
|
||||
/**
|
||||
* @psalm-type Category = array{label: string, value: string}
|
||||
* @psalm-type CategoryGroup = array{group: string, options: array<int, Category>}
|
||||
*/
|
||||
class CategoriesService {
|
||||
/** @var ICalendarManager */
|
||||
private $calendarManager;
|
||||
|
||||
/** @var ISystemTagManager */
|
||||
private $systemTagManager;
|
||||
|
||||
/** @var IL10N */
|
||||
private $l;
|
||||
|
||||
public function __construct(ICalendarManager $calendarManager,
|
||||
ISystemTagManager $systemTagManager,
|
||||
IL10N $l10n) {
|
||||
$this->calendarManager = $calendarManager;
|
||||
$this->systemTagManager = $systemTagManager;
|
||||
$this->l = $l10n;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a simplistic brute-force extraction of all already used
|
||||
* categories from all events accessible to the given user.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getUsedCategories(string $userId): array {
|
||||
$categories = [];
|
||||
$principalUri = 'principals/users/' . $userId;
|
||||
$query = $this->calendarManager->newQuery($principalUri);
|
||||
$query->addSearchProperty(ICalendarQuery::SEARCH_PROPERTY_CATEGORIES);
|
||||
$calendarObjects = $this->calendarManager->searchForPrincipal($query);
|
||||
foreach ($calendarObjects as $objectInfo) {
|
||||
foreach ($objectInfo['objects'] as $calendarObject) {
|
||||
if (isset($calendarObject['CATEGORIES'])) {
|
||||
$categories[] = explode(',', $calendarObject['CATEGORIES'][0][0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid injecting "broken" categories into the UI (avoid empty
|
||||
// categories and categories surrounded by spaces)
|
||||
$categories = array_filter(array_map(fn ($label) => trim($label), array_unique(array_merge(...$categories))));
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a grouped array with all previously used categories, all system
|
||||
* tags and all categories found in the iCalendar RFC.
|
||||
*
|
||||
* @return CategoryGroup[]
|
||||
*/
|
||||
public function getCategories(string $userId): array {
|
||||
$systemTags = $this->systemTagManager->getAllTags(true);
|
||||
|
||||
$systemTagCategoryLabels = [];
|
||||
/** @var ISystemTag $systemTag */
|
||||
foreach ($systemTags as $systemTag) {
|
||||
if (!$systemTag->isUserAssignable() || !$systemTag->isUserVisible()) {
|
||||
continue;
|
||||
}
|
||||
$systemTagCategoryLabels[] = $systemTag->getName();
|
||||
}
|
||||
sort($systemTagCategoryLabels);
|
||||
$systemTagCategoryLabels = array_values(array_filter(array_unique($systemTagCategoryLabels)));
|
||||
|
||||
$rfcCategoryLabels = [
|
||||
$this->l->t('Anniversary'),
|
||||
$this->l->t('Appointment'),
|
||||
$this->l->t('Business'),
|
||||
$this->l->t('Education'),
|
||||
$this->l->t('Holiday'),
|
||||
$this->l->t('Meeting'),
|
||||
$this->l->t('Miscellaneous'),
|
||||
$this->l->t('Non-working hours'),
|
||||
$this->l->t('Not in office'),
|
||||
$this->l->t('Personal'),
|
||||
$this->l->t('Phone call'),
|
||||
$this->l->t('Sick day'),
|
||||
$this->l->t('Special occasion'),
|
||||
$this->l->t('Travel'),
|
||||
$this->l->t('Vacation'),
|
||||
];
|
||||
sort($rfcCategoryLabels);
|
||||
$rfcCategoryLabels = array_values(array_filter(array_unique($rfcCategoryLabels)));
|
||||
|
||||
$standardCategories = array_merge($systemTagCategoryLabels, $rfcCategoryLabels);
|
||||
$customCategoryLabels = array_values(array_filter($this->getUsedCategories($userId), fn ($label) => !in_array($label, $standardCategories)));
|
||||
|
||||
$categories = [
|
||||
[
|
||||
'group' => $this->l->t('Custom Categories'),
|
||||
'options' => array_map(fn (string $label) => [ 'label' => $label, 'value' => $label ], $customCategoryLabels),
|
||||
],
|
||||
[
|
||||
'group' => $this->l->t('Collaborative Tags'),
|
||||
'options' => array_map(fn (string $label) => [ 'label' => $label, 'value' => $label ], $systemTagCategoryLabels),
|
||||
],
|
||||
[
|
||||
'group' => $this->l->t('Standard Categories'),
|
||||
'options' => array_map(fn (string $label) => [ 'label' => $label, 'value' => $label ], $rfcCategoryLabels),
|
||||
],
|
||||
];
|
||||
|
||||
return $categories;
|
||||
}
|
||||
}
|
|
@ -42,6 +42,9 @@
|
|||
:multiple="true"
|
||||
:taggable="true"
|
||||
track-by="label"
|
||||
group-values="options"
|
||||
group-label="group"
|
||||
:group-select="false"
|
||||
label="label"
|
||||
@select="selectValue"
|
||||
@tag="tag"
|
||||
|
@ -99,6 +102,10 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
customLabelHeading: {
|
||||
type: String,
|
||||
default: 'Custom Categories',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -111,45 +118,56 @@ export default {
|
|||
},
|
||||
options() {
|
||||
const options = this.propModel.options.slice()
|
||||
let customOptions = options.find((optionGroup) => optionGroup.group === this.customLabelHeading)
|
||||
if (!customOptions) {
|
||||
customOptions = {
|
||||
group: this.customLabelHeading,
|
||||
options: [],
|
||||
}
|
||||
options.unshift(customOptions)
|
||||
}
|
||||
for (const category of (this.selectionData ?? [])) {
|
||||
if (options.find(option => option.value === category.value)) {
|
||||
if (this.findOption(category, options)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add pseudo options for unknown values
|
||||
options.push({
|
||||
customOptions.options.push({
|
||||
value: category.value,
|
||||
label: category.label,
|
||||
})
|
||||
}
|
||||
|
||||
for (const category of this.value) {
|
||||
if (!options.find(option => option.value === category)) {
|
||||
options.push({ value: category, label: category })
|
||||
const categoryOption = { value: category, label: category }
|
||||
if (!this.findOption(categoryOption, options)) {
|
||||
customOptions.options.push(categoryOption)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.customLabelBuffer) {
|
||||
for (const category of this.customLabelBuffer) {
|
||||
if (!options.find(option => option.value === category.value)) {
|
||||
options.push(category)
|
||||
if (!this.findOption(category, options)) {
|
||||
customOptions.options.push(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
.sort((a, b) => {
|
||||
for (const optionGroup of options) {
|
||||
optionGroup.options = optionGroup.options.sort((a, b) => {
|
||||
return a.label.localeCompare(
|
||||
b.label,
|
||||
getLocale().replace('_', '-'),
|
||||
{ sensitivity: 'base' },
|
||||
)
|
||||
})
|
||||
}
|
||||
return options
|
||||
},
|
||||
},
|
||||
created() {
|
||||
for (const category of this.value) {
|
||||
const option = this.options.find(option => option.value === category)
|
||||
const option = this.findOption({ value: category }, this.options)
|
||||
if (option) {
|
||||
this.selectionData.push(option)
|
||||
}
|
||||
|
@ -172,7 +190,7 @@ export default {
|
|||
|
||||
// store removed custom options to keep it in the option list
|
||||
const options = this.propModel.options.slice()
|
||||
if (!options.find(option => option.value === value.value)) {
|
||||
if (!this.findOption(value, options)) {
|
||||
if (!this.customLabelBuffer) {
|
||||
this.customLabelBuffer = []
|
||||
}
|
||||
|
@ -187,6 +205,15 @@ export default {
|
|||
this.selectionData.push({ value, label: value })
|
||||
this.$emit('add-single-value', value)
|
||||
},
|
||||
findOption(value, availableOptions) {
|
||||
for (const optionGroup of availableOptions) {
|
||||
const option = optionGroup.options.find(option => option.value === value.value)
|
||||
if (option) {
|
||||
return option
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
|
||||
<template>
|
||||
<span class="property-select-multiple-colored-tag">
|
||||
<div class="property-select-multiple-colored-tag__color-indicator" :style="{ 'background-color': color}" />
|
||||
<div v-if="!isGroupLabel" class="property-select-multiple-colored-tag__color-indicator" :style="{ 'background-color': color }" />
|
||||
<span class="property-select-multiple-colored-tag__label">{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
@ -41,6 +41,9 @@ export default {
|
|||
},
|
||||
},
|
||||
computed: {
|
||||
isGroupLabel() {
|
||||
return this.option.$isLabel && this.option.$groupLabel
|
||||
},
|
||||
label() {
|
||||
const option = this.option
|
||||
logger.debug('Option render', { option })
|
||||
|
@ -48,7 +51,7 @@ export default {
|
|||
return this.option
|
||||
}
|
||||
|
||||
return this.option.label
|
||||
return this.option.$groupLabel ? this.option.$groupLabel : this.option.label
|
||||
},
|
||||
colorObject() {
|
||||
return uidToColor(this.label)
|
||||
|
|
|
@ -33,6 +33,7 @@ import {
|
|||
} from 'vuex'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { removeMailtoPrefix } from '../utils/attendee.js'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
|
||||
/**
|
||||
* This is a mixin for the editor. It contains common Vue stuff, that is
|
||||
|
@ -314,6 +315,11 @@ export default {
|
|||
rfcProps() {
|
||||
return getRFCProperties()
|
||||
},
|
||||
categoryOptions() {
|
||||
const categories = { ...this.rfcProps.categories }
|
||||
categories.options = loadState('calendar', 'categories')
|
||||
return categories
|
||||
},
|
||||
/**
|
||||
* Returns whether or not this event can be downloaded from the server
|
||||
*
|
||||
|
|
|
@ -147,8 +147,9 @@
|
|||
|
||||
<PropertySelectMultiple :colored-options="true"
|
||||
:is-read-only="isReadOnly"
|
||||
:prop-model="rfcProps.categories"
|
||||
:prop-model="categoryOptions"
|
||||
:value="categories"
|
||||
:custom-label-heading="t('calendar', 'Custom Categories')"
|
||||
@add-single-value="addCategory"
|
||||
@remove-single-value="removeCategory" />
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ namespace OCA\Calendar\Controller;
|
|||
use ChristophWurst\Nextcloud\Testing\TestCase;
|
||||
use OCA\Calendar\Db\AppointmentConfig;
|
||||
use OCA\Calendar\Service\Appointments\AppointmentConfigService;
|
||||
use OCA\Calendar\Service\CategoriesService;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\AppFramework\Services\IInitialState;
|
||||
|
@ -52,6 +53,9 @@ class ViewControllerTest extends TestCase {
|
|||
/** @var AppointmentConfigService|MockObject */
|
||||
private $appointmentContfigService;
|
||||
|
||||
/** @var CategoriesService|MockObject */
|
||||
private $categoriesService;
|
||||
|
||||
/** @var IInitialState|MockObject */
|
||||
private $initialStateService;
|
||||
|
||||
|
@ -70,6 +74,7 @@ class ViewControllerTest extends TestCase {
|
|||
$this->appManager = $this->createMock(IAppManager::class);
|
||||
$this->config = $this->createMock(IConfig::class);
|
||||
$this->appointmentContfigService = $this->createMock(AppointmentConfigService::class);
|
||||
$this->categoriesService = $this->createMock(CategoriesService::class);
|
||||
$this->initialStateService = $this->createMock(IInitialState::class);
|
||||
$this->userId = 'user123';
|
||||
$this->appData = $this->createMock(IAppData::class);
|
||||
|
@ -79,6 +84,7 @@ class ViewControllerTest extends TestCase {
|
|||
$this->request,
|
||||
$this->config,
|
||||
$this->appointmentContfigService,
|
||||
$this->categoriesService,
|
||||
$this->initialStateService,
|
||||
$this->appManager,
|
||||
$this->userId,
|
||||
|
@ -133,7 +139,18 @@ class ViewControllerTest extends TestCase {
|
|||
->method('getAllAppointmentConfigurations')
|
||||
->with($this->userId)
|
||||
->willReturn([new AppointmentConfig()]);
|
||||
|
||||
$this->categoriesService->expects(self::once())
|
||||
->method('getCategories')
|
||||
->with('user123')
|
||||
->willReturn([
|
||||
[
|
||||
'group' => 'Test',
|
||||
'options' => [
|
||||
'label' => 'hawaii',
|
||||
'value' => 'pizza',
|
||||
],
|
||||
],
|
||||
]);
|
||||
$this->initialStateService
|
||||
->method('provideInitialState')
|
||||
->withConsecutive(
|
||||
|
@ -155,6 +172,17 @@ class ViewControllerTest extends TestCase {
|
|||
['hide_event_export', true],
|
||||
['force_event_alarm_type', null],
|
||||
['appointmentConfigs', [new AppointmentConfig()]],
|
||||
['disable_appointments', false],
|
||||
['can_subscribe_link', false],
|
||||
['categories', [
|
||||
[
|
||||
'group' => 'Test',
|
||||
'options' => [
|
||||
'label' => 'hawaii',
|
||||
'value' => 'pizza',
|
||||
],
|
||||
],
|
||||
]],
|
||||
);
|
||||
|
||||
$response = $this->controller->index();
|
||||
|
|
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
*
|
||||
* @author 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace OCA\Calendar\Tests\Unit\Service;
|
||||
|
||||
use ChristophWurst\Nextcloud\Testing\ServiceMockObject;
|
||||
use ChristophWurst\Nextcloud\Testing\TestCase;
|
||||
use OCA\Calendar\Service\CategoriesService;
|
||||
use OCP\SystemTag\ISystemTag;
|
||||
use function array_column;
|
||||
|
||||
class CategoriesServiceTest extends TestCase {
|
||||
private ServiceMockObject $serviceMock;
|
||||
private CategoriesService $service;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->serviceMock = $this->createServiceMock(CategoriesService::class);
|
||||
$this->service = $this->serviceMock->getService();
|
||||
|
||||
$this->serviceMock->getParameter('l10n')
|
||||
->method('t')
|
||||
->willReturnArgument(0);
|
||||
}
|
||||
|
||||
public function testGetCategoriesDefaultsOnly(): void {
|
||||
$categories = $this->service->getCategories('user123');
|
||||
|
||||
self::assertCount(3, $categories);
|
||||
self::assertEquals(
|
||||
[
|
||||
'Custom Categories',
|
||||
'Collaborative Tags',
|
||||
'Standard Categories',
|
||||
],
|
||||
array_column($categories, 'group')
|
||||
);
|
||||
self::assertCount(0, $categories[0]['options']);
|
||||
self::assertCount(0, $categories[1]['options']);
|
||||
self::assertCount(15, $categories[2]['options']);
|
||||
}
|
||||
|
||||
public function testGetUsedCategories(): void {
|
||||
$this->serviceMock->getParameter('calendarManager')
|
||||
->expects(self::once())
|
||||
->method('searchForPrincipal')
|
||||
->willReturn([
|
||||
[
|
||||
'objects' => [],
|
||||
],
|
||||
[
|
||||
'objects' => [
|
||||
[
|
||||
'CATEGORIES' => [
|
||||
[
|
||||
'',
|
||||
[],
|
||||
]
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'objects' => [
|
||||
[
|
||||
'CATEGORIES' => [
|
||||
[
|
||||
'pizza,party',
|
||||
[],
|
||||
]
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'objects' => [
|
||||
[
|
||||
'CATEGORIES' => [
|
||||
[
|
||||
'pizza,hawaii',
|
||||
[],
|
||||
]
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$categories = $this->service->getCategories('user123');
|
||||
|
||||
self::assertArrayHasKey(0, $categories);
|
||||
self::assertCount(3, $categories[0]['options']);
|
||||
self::assertEquals(['pizza', 'party', 'hawaii'], array_column($categories[0]['options'], 'label'));
|
||||
}
|
||||
|
||||
public function testGetSystemTagsAsCategories(): void {
|
||||
$tag1 = $this->createMock(ISystemTag::class);
|
||||
$tag1->method('isUserAssignable')->willReturn(false);
|
||||
$tag1->method('isUserVisible')->willReturn(true);
|
||||
$tag2 = $this->createMock(ISystemTag::class);
|
||||
$tag2->method('isUserAssignable')->willReturn(false);
|
||||
$tag2->method('isUserVisible')->willReturn(false);
|
||||
$tag3 = $this->createMock(ISystemTag::class);
|
||||
$tag3->method('isUserAssignable')->willReturn(true);
|
||||
$tag3->method('isUserVisible')->willReturn(true);
|
||||
$tag3->method('getName')->willReturn('fun');
|
||||
$this->serviceMock->getParameter('systemTagManager')
|
||||
->expects(self::once())
|
||||
->method('getAllTags')
|
||||
->with(true)
|
||||
->willReturn([
|
||||
$tag1,
|
||||
$tag2,
|
||||
$tag3,
|
||||
]);
|
||||
|
||||
$categories = $this->service->getCategories('user123');
|
||||
|
||||
self::assertArrayHasKey(1, $categories);
|
||||
self::assertCount(1, $categories[1]['options']);
|
||||
self::assertEquals(['fun'], array_column($categories[1]['options'], 'label'));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue