diff --git a/lib/Analytics/AnalyticsDatasource.php b/lib/Analytics/AnalyticsDatasource.php index 5a61416fd0..170f35ddc8 100644 --- a/lib/Analytics/AnalyticsDatasource.php +++ b/lib/Analytics/AnalyticsDatasource.php @@ -8,6 +8,7 @@ namespace OCA\Tables\Analytics; use OCA\Analytics\Datasource\IDatasource; +use OCA\Tables\Db\Column; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; @@ -21,6 +22,7 @@ use Psr\Log\LoggerInterface; class AnalyticsDatasource implements IDatasource { + private LoggerInterface $logger; private IL10N $l10n; private TableService $tableService; @@ -120,9 +122,24 @@ public function getTemplate(): array { $tableString = $tableString . $view->getTableId() . ':' . $view->getId() . '-' . $view->getTitle() . '/'; } // add the tables to a dropdown in the data source settings - $template[] = ['id' => 'tableId', 'name' => $this->l10n->t('Select table'), 'type' => 'tf', 'placeholder' => $tableString]; - $template[] = ['id' => 'columns', 'name' => $this->l10n->t('Select columns'), 'placeholder' => $this->l10n->t('e.g. 1,2,4 or leave empty'), 'type' => 'columnPicker']; - $template[] = ['id' => 'timestamp', 'name' => $this->l10n->t('Timestamp of data load'), 'placeholder' => 'false-' . $this->l10n->t('No') . '/true-' . $this->l10n->t('Yes'), 'type' => 'tf']; + $template[] = [ + 'id' => 'tableId', + 'name' => $this->l10n->t('Select table'), + 'type' => 'tf', + 'placeholder' => $tableString + ]; + $template[] = [ + 'id' => 'columns', + 'name' => $this->l10n->t('Select columns'), + 'placeholder' => $this->l10n->t('e.g. 1,2,4 or leave empty'), + 'type' => 'columnPicker' + ]; + $template[] = [ + 'id' => 'timestamp', + 'name' => $this->l10n->t('Timestamp of data load'), + 'placeholder' => 'false-' . $this->l10n->t('No') . '/true-' . $this->l10n->t('Yes'), + 'type' => 'tf' + ]; return $template; } @@ -166,7 +183,7 @@ public function readData($option): array { // get the selected columns from the data source options $selectedColumns = []; if (isset($option['columns']) && strlen($option['columns']) > 0) { - $selectedColumns = str_getcsv($option['columns']); + $selectedColumns = str_getcsv($option['columns'], ',', '"', '\\'); } $data = []; @@ -183,8 +200,12 @@ public function readData($option): array { } unset($rows); - return ['header' => $header, 'dimensions' => array_slice($header, 0, count($header) - 1), 'data' => $data, //'rawdata' => $data, - 'error' => 0,]; + return [ + 'header' => $header, + 'dimensions' => array_slice($header, 0, count($header) - 1), + 'data' => $data, //'rawdata' => $data, + 'error' => 0, + ]; } /** @@ -219,6 +240,7 @@ private function getData(int $nodeId, ?int $limit, ?int $offset, ?string $nodeTy foreach ($columns as $column) { $header[] = $column->getTitle(); } + $header[] = $this->l10n->t('Count'); $data[] = $header; // now add the rows @@ -229,29 +251,171 @@ private function getData(int $nodeId, ?int $limit, ?int $offset, ?string $nodeTy $value = ''; foreach ($rowData as $datum) { if ($datum['columnId'] === $column->getId()) { - // if column type selection, the corresponding labels need to be fetched - if ($column->getType() === 'selection') { - foreach ($column->getSelectionOptionsArray() as $option) { - if ($option['id'] === $datum['value']) { - $value = $option['label']; - } - } - } else { - $value = $datum['value']; - } + $value = $this->formatValue($column, $datum['value']); } } - // Tables does not deliver any values for "blank" default columns - if ($value === '' && $column->getType() === 'number') { - $value = $column->getNumberDefault(); + // Tables does not deliver any values for "blank" default columns. + if ($value === '') { + $value = $this->formatDefaultValue($column); } $line[] = $value; } + $line[] = 1; // constant 1 for the count column $data[] = $line; } return $data; } + private function formatValue(Column $column, mixed $value): mixed { + return match ($column->getType()) { + Column::TYPE_SELECTION => $this->formatSelectionValue($column, $value), + Column::TYPE_TEXT => $this->formatTextValue($column, $value), + Column::TYPE_USERGROUP => $this->formatUsergroupValue($value), + default => $value, + }; + } + + private function formatDefaultValue(Column $column): mixed { + return match ($column->getType()) { + Column::TYPE_NUMBER => $column->getNumberDefault() ?? '', + Column::TYPE_SELECTION => $this->formatSelectionValue($column, $this->parseDefaultValue($column->getSelectionDefault())), + Column::TYPE_TEXT => $this->formatTextValue($column, $column->getTextDefault() ?? ''), + Column::TYPE_DATETIME => $this->formatDatetimeDefaultValue($column), + Column::TYPE_USERGROUP => $this->formatUsergroupValue($this->parseDefaultValue($column->getUsergroupDefault())), + default => '', + }; + } + + private function formatSelectionValue(Column $column, mixed $value): mixed { + if ($column->getSubtype() === Column::SUBTYPE_SELECTION_CHECK) { + return $this->formatBooleanValue($value); + } + + if ($this->isMultiSelection($column)) { + return implode(', ', $this->getSelectionLabels($column, $this->normalizeArrayValue($value))); + } + if ($value === null || $value === '') { + return ''; + } + + foreach ($column->getSelectionOptionsArray() as $option) { + if ((int)$option['id'] === (int)$value) { + return $option['label']; + } + } + + return ''; + } + + private function isMultiSelection(Column $column): bool { + return in_array($column->getSubtype(), [Column::SUBTYPE_SELECTION_MULTI, 'multi'], true); + } + + /** + * @param list $values + * @return list + */ + private function getSelectionLabels(Column $column, array $values): array { + $labels = []; + foreach ($values as $value) { + foreach ($column->getSelectionOptionsArray() as $option) { + if ((int)$option['id'] === (int)$value) { + $labels[] = $option['label']; + break; + } + } + } + return $labels; + } + + private function formatBooleanValue(mixed $value): string { + if ($value === true || $value === 1 || $value === '1' || $value === 'true' || $value === 'TRUE') { + return 'true'; + } + if ($value === false || $value === 0 || $value === '0' || $value === 'false' || $value === 'FALSE') { + return 'false'; + } + return ''; + } + + private function formatTextValue(Column $column, mixed $value): string { + if ($value === null || $value === '') { + return ''; + } + + $value = (string)$value; + if ($column->getSubtype() === 'link') { + return $this->formatLinkValue($value); + } + + return trim(strip_tags($value)); + } + + private function formatLinkValue(string $value): string { + $data = json_decode($value, true); + if (is_array($data)) { + $title = $data['title'] ?? ''; + $link = $data['resourceUrl'] ?? $data['value'] ?? ''; + if ($title !== '' && $link !== '') { + return $title . ' (' . $link . ')'; + } + return $link ?: $title; + } + + return $value; + } + + private function formatDatetimeDefaultValue(Column $column): string { + return match ($column->getDatetimeDefault()) { + 'today' => date('Y-m-d'), + 'now' => $column->getSubtype() === Column::SUBTYPE_DATETIME_TIME ? date('H:i') : date('Y-m-d H:i'), + default => '', + }; + } + + private function formatUsergroupValue(mixed $value): string { + $items = $this->normalizeArrayValue($value); + $labels = []; + + foreach ($items as $item) { + if (is_array($item)) { + $labels[] = (string)($item['displayName'] ?? $item['id'] ?? ''); + } else { + $labels[] = (string)$item; + } + } + + return implode(', ', array_filter($labels, static fn (string $label): bool => $label !== '')); + } + + private function parseDefaultValue(?string $value): mixed { + if ($value === null || $value === '') { + return ''; + } + + $decoded = json_decode($value, true); + return json_last_error() === JSON_ERROR_NONE ? $decoded : $value; + } + + /** + * @return list + */ + private function normalizeArrayValue(mixed $value): array { + if ($value === null || $value === '') { + return []; + } + if (is_array($value)) { + return array_is_list($value) ? $value : [$value]; + } + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $this->normalizeArrayValue($decoded); + } + } + return [$value]; + } + /** * filter only the selected columns in the given sequence * diff --git a/tests/unit/Analytics/AnalyticsDatasourceTest.php b/tests/unit/Analytics/AnalyticsDatasourceTest.php new file mode 100644 index 0000000000..4d411bfe6d --- /dev/null +++ b/tests/unit/Analytics/AnalyticsDatasourceTest.php @@ -0,0 +1,245 @@ +l10n = $this->createMock(IL10N::class); + $this->l10n->method('t') + ->willReturnCallback(static fn (string $text): string => $text); + + $this->tableService = $this->createMock(TableService::class); + $this->viewService = $this->createMock(ViewService::class); + $this->columnService = $this->createMock(ColumnService::class); + $this->rowService = $this->createMock(RowService::class); + + $this->datasource = new AnalyticsDatasource( + $this->l10n, + $this->createMock(LoggerInterface::class), + $this->tableService, + $this->viewService, + $this->columnService, + $this->rowService, + 'user1', + ); + } + + public function testReadDataAddsCountColumn(): void { + $this->mockTableData(); + + $result = $this->datasource->readData([ + 'tableId' => '123', + 'user_id' => 'user1', + ]); + + self::assertSame(['Name', 'Amount', 'Count'], $result['header']); + self::assertSame(['Name', 'Amount'], $result['dimensions']); + self::assertSame([ + ['Alpha', 5, 1], + ['Beta', 7, 1], + ], $result['data']); + self::assertSame(0, $result['error']); + } + + public function testReadDataCanSelectCountColumn(): void { + $this->mockTableData(); + + $result = $this->datasource->readData([ + 'tableId' => '123', + 'user_id' => 'user1', + 'columns' => '1,3', + ]); + + self::assertSame(['Name', 'Count'], $result['header']); + self::assertSame(['Name'], $result['dimensions']); + self::assertSame([ + ['Alpha', 1], + ['Beta', 1], + ], $result['data']); + } + + public function testReadDataFormatsDisplayValuesForAnalytics(): void { + $this->columnService + ->expects($this->once()) + ->method('findAllByTable') + ->with(123, 'user1') + ->willReturn([ + $this->createColumn(1, 'Link', 'text', 'link'), + $this->createColumn(2, 'Notes', 'text', 'rich'), + $this->createSelectionColumn(3, 'Status', '', [ + ['id' => 1, 'label' => 'Open'], + ['id' => 2, 'label' => 'Closed'], + ]), + $this->createSelectionColumn(4, 'Tags', 'multi', [ + ['id' => 1, 'label' => 'Important'], + ['id' => 2, 'label' => 'Later'], + ]), + $this->createColumn(5, 'Done', 'selection', 'check'), + $this->createColumn(6, 'People', 'usergroup'), + $this->createColumn(7, 'Untitled link', 'text', 'link'), + ]); + + $row = new Row2(); + $row->setData([ + ['columnId' => 1, 'value' => '{"title":"Issue","value":"https://example.test/issue","providerId":"url"}'], + ['columnId' => 2, 'value' => '

Hello world

'], + ['columnId' => 3, 'value' => 2], + ['columnId' => 4, 'value' => [1, 2]], + ['columnId' => 5, 'value' => 'true'], + ['columnId' => 6, 'value' => [ + ['id' => 'user1', 'type' => 0, 'displayName' => 'User One'], + ['id' => 'group1', 'type' => 1], + ]], + ['columnId' => 7, 'value' => '{"title":"","value":"https://example.test/plain","providerId":"url"}'], + ]); + + $this->rowService + ->expects($this->once()) + ->method('findAllByTable') + ->with(123, 'user1', null, null) + ->willReturn([$row]); + + $result = $this->datasource->readData([ + 'tableId' => '123', + 'user_id' => 'user1', + ]); + + self::assertSame([ + [ + 'Issue (https://example.test/issue)', + 'Hello world', + 'Closed', + 'Important, Later', + 'true', + 'User One, group1', + 'https://example.test/plain', + 1, + ], + ], $result['data']); + } + + public function testReadDataFormatsDefaultValuesForAnalytics(): void { + $numberColumn = $this->createColumn(1, 'Amount', 'number'); + $numberColumn->setNumberDefault(10); + $textColumn = $this->createColumn(2, 'Title', 'text', 'line'); + $textColumn->setTextDefault('Fallback'); + $selectionColumn = $this->createSelectionColumn(3, 'Status', '', [ + ['id' => 1, 'label' => 'Open'], + ]); + $selectionColumn->setSelectionDefault('1'); + $multiSelectionColumn = $this->createSelectionColumn(4, 'Tags', 'multi', [ + ['id' => 1, 'label' => 'Important'], + ['id' => 2, 'label' => 'Later'], + ]); + $multiSelectionColumn->setSelectionDefault('[1,2]'); + $checkColumn = $this->createColumn(5, 'Done', 'selection', 'check'); + $checkColumn->setSelectionDefault('false'); + $usergroupColumn = $this->createColumn(6, 'People', 'usergroup'); + $usergroupColumn->setUsergroupDefault('[{"id":"user1","type":0,"displayName":"User One"}]'); + + $this->columnService + ->expects($this->once()) + ->method('findAllByTable') + ->with(123, 'user1') + ->willReturn([ + $numberColumn, + $textColumn, + $selectionColumn, + $multiSelectionColumn, + $checkColumn, + $usergroupColumn, + ]); + + $row = new Row2(); + $row->setData([]); + + $this->rowService + ->expects($this->once()) + ->method('findAllByTable') + ->with(123, 'user1', null, null) + ->willReturn([$row]); + + $result = $this->datasource->readData([ + 'tableId' => '123', + 'user_id' => 'user1', + ]); + + self::assertSame([ + [10.0, 'Fallback', 'Open', 'Important, Later', 'false', 'User One', 1], + ], $result['data']); + } + + private function mockTableData(): void { + $this->columnService + ->expects($this->once()) + ->method('findAllByTable') + ->with(123, 'user1') + ->willReturn([ + $this->createColumn(1, 'Name', 'text'), + $this->createColumn(2, 'Amount', 'number'), + ]); + + $this->rowService + ->expects($this->once()) + ->method('findAllByTable') + ->with(123, 'user1', null, null) + ->willReturn([ + $this->createRow('Alpha', 5), + $this->createRow('Beta', 7), + ]); + } + + private function createColumn(int $id, string $title, string $type, string $subtype = ''): Column { + $column = new Column(); + $column->setId($id); + $column->setTitle($title); + $column->setType($type); + $column->setSubtype($subtype); + return $column; + } + + private function createSelectionColumn(int $id, string $title, string $subtype, array $options): Column { + $column = $this->createColumn($id, $title, 'selection', $subtype); + $column->setSelectionOptions(json_encode($options)); + return $column; + } + + private function createRow(string $name, int $amount): Row2 { + $row = new Row2(); + $row->setData([ + ['columnId' => 1, 'value' => $name], + ['columnId' => 2, 'value' => $amount], + ]); + return $row; + } +}