diff --git a/lib/Controller/RowController.php b/lib/Controller/RowController.php
index 144089f7e4..caa0e7ee27 100644
--- a/lib/Controller/RowController.php
+++ b/lib/Controller/RowController.php
@@ -28,19 +28,29 @@ public function __construct(
parent::__construct(Application::APP_ID, $request);
}
+ /**
+ * @param int $tableId ID of the table
+ * @param string $customFilters JSON-encoded array of filter groups to apply
+ */
#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')]
- public function index(int $tableId): DataResponse {
- return $this->handleError(function () use ($tableId) {
- return $this->service->findAllByTable($tableId, $this->userId);
+ public function index(int $tableId, string $customFilters = ''): DataResponse {
+ return $this->handleError(function () use ($tableId, $customFilters) {
+ $customFilters = json_decode($customFilters, true) ?? [];
+ return $this->service->findAllByTable($tableId, $this->userId, customFilters: $customFilters);
});
}
+ /**
+ * @param int $viewId ID of the view
+ * @param string $customFilters JSON-encoded array of filter groups to apply
+ */
#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')]
- public function indexView(int $viewId): DataResponse {
- return $this->handleError(function () use ($viewId) {
- return $this->service->findAllByView($viewId, $this->userId);
+ public function indexView(int $viewId, string $customFilters = ''): DataResponse {
+ return $this->handleError(function () use ($viewId, $customFilters) {
+ $customFilters = json_decode($customFilters, true) ?? [];
+ return $this->service->findAllByView($viewId, $this->userId, customFilters: $customFilters);
});
}
diff --git a/lib/Db/ColumnMapper.php b/lib/Db/ColumnMapper.php
index 527b728d97..270c8e529b 100644
--- a/lib/Db/ColumnMapper.php
+++ b/lib/Db/ColumnMapper.php
@@ -190,7 +190,7 @@ public function countColumns(int $tableId): int {
* This method efficiently loads column data for a given set of columns, filters, and sorts
* by fetching all required data in a single database operation.
*/
- public function preloadColumns(array $columns, ?array $filters = null, ?array $sorts = null): void {
+ public function preloadColumns(array $columns, ?array $filters = null, ?array $sorts = null, array $customFilters = []): void {
$columnIds = $columns;
if (!is_null($sorts) && count($sorts) > 0) {
$columnIds = [...$columns, ...array_column($sorts, 'columnId')];
@@ -200,7 +200,11 @@ public function preloadColumns(array $columns, ?array $filters = null, ?array $s
array_push($columnIds, ...array_column($filterGroup, 'columnId'));
}
}
-
+ if (count($customFilters) > 0) {
+ foreach ($customFilters as $filterGroup) {
+ array_push($columnIds, ...array_column($filterGroup, 'columnId'));
+ }
+ }
$this->findAll(array_unique($columnIds));
}
diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php
index 7ec48500f9..2dd49caaa5 100644
--- a/lib/Db/Row2Mapper.php
+++ b/lib/Db/Row2Mapper.php
@@ -132,15 +132,15 @@ public function setUserId(string $userId): void {
* @return int[]
* @throws InternalError
*/
- private function getWantedRowIds(string $userId, int $tableId, ?array $filter = null, ?array $sort = null, ?int $limit = null, ?int $offset = null): array {
+ private function getWantedRowIds(string $userId, int $tableId, ?array $filter = null, ?array $sort = null, ?int $limit = null, ?int $offset = null, array $customFilters = []): array {
$qb = $this->db->getQueryBuilder();
$qb->select('sleeves.id')
->from('tables_row_sleeves', 'sleeves')
->where($qb->expr()->eq('table_id', $qb->createNamedParameter($tableId, IQueryBuilder::PARAM_INT)));
- if ($filter) {
- $this->addFilterToQuery($qb, $filter, $userId);
+ if ($filter || $customFilters) {
+ $this->addFilterToQuery($qb, $filter ?? [], $customFilters, $userId);
}
$this->addSortQueryForMultipleSleeveFinder($qb, 'sleeves', $sort);
@@ -172,14 +172,15 @@ private function getWantedRowIds(string $userId, int $tableId, ?array $filter =
* @param array|null $filter
* @param array|null $sort
* @param string|null $userId
+ * @param array $customFilters
* @return Row2[]
* @throws InternalError
*/
- public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, ?int $offset = null, ?array $filter = null, ?array $sort = null, ?string $userId = null): array {
+ public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, ?int $offset = null, ?array $filter = null, ?array $sort = null, ?string $userId = null, array $customFilters = []): array {
try {
$this->columnMapper->preloadColumns($showColumnIds, $filter, $sort);
- $wantedRowIdsArray = $this->getWantedRowIds($userId, $tableId, $filter, $sort, $limit, $offset);
+ $wantedRowIdsArray = $this->getWantedRowIds($userId, $tableId, $filter, $sort, $limit, $offset, $customFilters);
// Get rows without SQL sorting
$rows = $this->getRows($wantedRowIdsArray, $showColumnIds);
@@ -273,16 +274,22 @@ private function getRowsChunk(array $rowIds, array $columnIds): array {
/**
* @throws InternalError
*/
- private function addFilterToQuery(IQueryBuilder $qb, array $filters, string $userId): void {
- // TODO move this into service
- $this->replacePlaceholderValues($filters, $userId);
-
+ private function addFilterToQuery(IQueryBuilder $qb, array $filters, array $customFilters, string $userId): void {
+ $conditions = [];
if (count($filters) > 0) {
- $qb->andWhere(
- $qb->expr()->orX(
- ...$this->getFilterGroups($qb, $filters)
- )
- );
+ $this->replacePlaceholderValues($filters, $userId);
+ $conditions[] = $qb->expr()->orX(...$this->getFilterGroups($qb, $filters));
+ }
+
+ if (count($customFilters) > 0) {
+ $this->replacePlaceholderValues($customFilters, $userId);
+ $conditions[] = $qb->expr()->orX(...$this->getFilterGroups($qb, $customFilters));
+ }
+
+ if (count($conditions) == 1) {
+ $qb->andWhere($conditions[0]);
+ } elseif (count($conditions) > 1) {
+ $qb->andWhere($qb->expr()->andX(...$conditions));
}
}
@@ -350,7 +357,7 @@ private function addSortQueryForMultipleSleeveFinder(IQueryBuilder $qb, string $
private function replacePlaceholderValues(array &$filters, string $userId): void {
foreach ($filters as &$filterGroup) {
foreach ($filterGroup as &$filter) {
- if (str_starts_with($filter['value'], '@')) {
+ if (is_string($filter['value']) && str_starts_with($filter['value'], '@')) {
$columnId = (int)($filter['columnId'] ?? 0);
$column = $columnId > 0 ? $this->columnMapper->find($columnId) : null;
$filter['value'] = $this->columnsHelper->resolveSearchValue($filter['value'], $userId, $column);
@@ -593,14 +600,14 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $
/**
* @throws InternalError
*/
- private function getMetaFilterExpression(IQueryBuilder $qb, int $columnId, string $operator, string $value): IQueryBuilder {
+ private function getMetaFilterExpression(IQueryBuilder $qb, int $columnId, string $operator, string|array $value): IQueryBuilder {
$qb2 = $this->db->getQueryBuilder();
$qb2->select('id');
$qb2->from('tables_row_sleeves');
switch ($columnId) {
case Column::TYPE_META_ID:
- $qb2->where($this->getSqlOperator($operator, $qb, 'id', (int)$value, IQueryBuilder::PARAM_INT));
+ $qb2->where($this->getSqlOperator($operator, $qb, 'id', (array)$value, IQueryBuilder::PARAM_INT_ARRAY));
break;
case Column::TYPE_META_CREATED_BY:
$qb2->where($this->getSqlOperator($operator, $qb, 'created_by', $value, IQueryBuilder::PARAM_STR));
@@ -638,6 +645,9 @@ private function getSqlOperator(string $operator, IQueryBuilder $qb, string $col
case 'does-not-contain':
return $qb->expr()->notLike($columnName, $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%', $paramType));
case 'is-equal':
+ if (is_array($value)) {
+ return $qb->expr()->in($columnName, $qb->createNamedParameter($value, $paramType));
+ }
return $qb->expr()->eq($columnName, $qb->createNamedParameter($value, $paramType));
case 'is-not-equal':
return $qb->expr()->neq($columnName, $qb->createNamedParameter($value, $paramType));
diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php
index 9e800499ef..b57df94d50 100644
--- a/lib/Service/RowService.php
+++ b/lib/Service/RowService.php
@@ -90,11 +90,12 @@ public function formatRowsForPublicShare(array $rows): array {
* @param string $userId
* @param ?int $limit
* @param ?int $offset
+ * @param array $customFilters
* @return Row2[]
* @throws InternalError
* @throws PermissionError
*/
- public function findAllByTable(int $tableId, string $userId, ?int $limit = null, ?int $offset = null): array {
+ public function findAllByTable(int $tableId, string $userId, ?int $limit = null, ?int $offset = null, array $customFilters = []): array {
try {
if ($this->permissionsService->canReadRowsByElementId($tableId, 'table', $userId)) {
$tableColumns = $this->columnMapper->findAllByTable($tableId);
@@ -103,7 +104,7 @@ public function findAllByTable(int $tableId, string $userId, ?int $limit = null,
$table = $this->tableMapper->find($tableId);
$sort = $table->getSortArray() ?: null;
- $rows = $this->row2Mapper->findAll($showColumnIds, $tableId, $limit, $offset, null, $sort, $userId);
+ $rows = $this->row2Mapper->findAll($showColumnIds, $tableId, $limit, $offset, null, $sort, $userId, $customFilters);
$this->attachAliasPayloads($rows, $tableColumns);
return $rows;
} else {
@@ -120,13 +121,14 @@ public function findAllByTable(int $tableId, string $userId, ?int $limit = null,
* @param string $userId
* @param int|null $limit
* @param int|null $offset
+ * @param array $customFilters
* @return Row2[]
* @throws DoesNotExistException
* @throws InternalError
* @throws MultipleObjectsReturnedException
* @throws PermissionError
*/
- public function findAllByView(int $viewId, string $userId, ?int $limit = null, ?int $offset = null): array {
+ public function findAllByView(int $viewId, string $userId, ?int $limit = null, ?int $offset = null, array $customFilters = []): array {
try {
if ($this->permissionsService->canReadRowsByElementId($viewId, 'view', $userId)) {
$view = $this->viewMapper->find($viewId);
@@ -139,6 +141,7 @@ public function findAllByView(int $viewId, string $userId, ?int $limit = null, ?
$view->getFilterArray(),
$view->getSortArray(),
$this->resolveFilterUserId($userId, $view),
+ $customFilters,
);
} else {
diff --git a/src/modules/main/sections/ElementTitle.vue b/src/modules/main/sections/ElementTitle.vue
index e8c30e9334..7cef2c60dd 100644
--- a/src/modules/main/sections/ElementTitle.vue
+++ b/src/modules/main/sections/ElementTitle.vue
@@ -74,12 +74,28 @@ export default {
},
isViewSettingSet() {
+ if (this.$route.query.customFilters?.length > 0) {
+ return true
+ }
+
return !(!this.viewSetting || ((!this.viewSetting.hiddenColumns || this.viewSetting.hiddenColumns.length === 0) && (!this.viewSetting.sorting) && (!this.viewSetting.filter || this.viewSetting.filter.length === 0)))
},
},
methods: {
resetLocalAdjustments() {
+ if (this.$route.query.customFilters?.length > 0) {
+ this.$router.push({
+ path: this.$route.path,
+ query: {
+ ...this.$route.query,
+ customFilters: undefined,
+ },
+ })
+
+ return
+ }
+
this.$emit('update:viewSetting', {})
},
},
diff --git a/src/modules/main/sections/MainWrapper.vue b/src/modules/main/sections/MainWrapper.vue
index 723939de4f..968cb3aa25 100644
--- a/src/modules/main/sections/MainWrapper.vue
+++ b/src/modules/main/sections/MainWrapper.vue
@@ -86,6 +86,9 @@ export default {
computed: {
...mapState(useTablesStore, ['activeRowId']),
+ customFiltered() {
+ return this.$route.query.customFilters
+ },
},
watch: {
@@ -95,6 +98,9 @@ export default {
activeRowId() {
this.reload()
},
+ customFiltered() {
+ this.reload(true)
+ },
},
beforeMount() {
@@ -183,6 +189,7 @@ export default {
await this.loadRowsFromBE({
viewId: this.isView ? this.element.id : null,
tableId: !this.isView ? this.element.id : null,
+ customFilters: this.$route.query.customFilters,
})
} else {
await this.removeRows({
diff --git a/src/shared/components/ncTable/sections/Options.vue b/src/shared/components/ncTable/sections/Options.vue
index 7f2ade57bf..d5772f317d 100644
--- a/src/shared/components/ncTable/sections/Options.vue
+++ b/src/shared/components/ncTable/sections/Options.vue
@@ -44,6 +44,12 @@
{{ t('tables', 'Export filtered rows') }}
+
+
+
+
+ {{ t('tables', 'Share rows') }}
+
@@ -70,10 +76,13 @@ import Plus from 'vue-material-design-icons/Plus.vue'
import Check from 'vue-material-design-icons/CheckboxBlankOutline.vue'
import Delete from 'vue-material-design-icons/TrashCanOutline.vue'
import TrayArrowDown from 'vue-material-design-icons/TrayArrowDown.vue'
+import ShareVariantOutline from 'vue-material-design-icons/ShareVariantOutline.vue'
import viewportHelper from '../../../mixins/viewportHelper.js'
+import copyToClipboard from '../../../mixins/copyToClipboard.js'
import SearchForm from '../partials/SearchForm.vue'
import PaginationBlock from './PaginationBlock.vue'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+import { TYPE_META_ID } from '../../../constants.ts'
export default {
name: 'Options',
@@ -87,10 +96,11 @@ export default {
Check,
Delete,
TrayArrowDown,
+ ShareVariantOutline,
PaginationBlock,
},
- mixins: [viewportHelper],
+ mixins: [viewportHelper, copyToClipboard],
props: {
selectedRows: {
@@ -193,6 +203,23 @@ export default {
deselectAllRows() {
emit('tables:selected-rows:deselect', { elementId: this.elementId, isView: this.isView })
},
+ async shareRows() {
+ await this.$router.push({
+ path: this.$route.path,
+ query: {
+ ...this.$route.query,
+ customFilters: JSON.stringify([[
+ {
+ columnId: TYPE_META_ID,
+ operator: 'is-equal',
+ value: this.selectedRows,
+ },
+ ]]),
+ },
+ })
+
+ this.copyToClipboard(document.location.href, false)
+ },
},
}
diff --git a/src/store/data.js b/src/store/data.js
index 75fc23a92f..3a0c5f5417 100644
--- a/src/store/data.js
+++ b/src/store/data.js
@@ -233,16 +233,16 @@ export const useDataStore = defineStore('data', {
},
// ROWS
- async loadRowsFromBE({ tableId, viewId }) {
+ async loadRowsFromBE({ tableId, viewId, customFilters = null }) {
const stateId = genStateKey(!!(viewId), viewId ?? tableId)
this.loading[stateId] = true
let res = null
try {
if (viewId) {
- res = await axios.get(generateUrl('/apps/tables/row/view/' + viewId))
+ res = await axios.get(generateUrl('/apps/tables/row/view/' + viewId), { params: { customFilters } })
} else {
- res = await axios.get(generateUrl('/apps/tables/row/table/' + tableId))
+ res = await axios.get(generateUrl('/apps/tables/row/table/' + tableId), { params: { customFilters } })
}
} catch (e) {
displayError(e, t('tables', 'Could not load rows.'))
diff --git a/tests/integration/features/APIv2.feature b/tests/integration/features/APIv2.feature
index 09c5361c22..b9e98701cc 100644
--- a/tests/integration/features/APIv2.feature
+++ b/tests/integration/features/APIv2.feature
@@ -561,8 +561,8 @@ Feature: APIv2
| t2 | table | read |
And user "participant1-v2" shares the Context "c1" to "user" "participant2-v2" with permissions "read,create,update,delete,manage"
When user "participant2-v2" transfers the Context "c1" to "participant3-v2"
- Then the reported status is "403"
-
+ Then the reported status is "403"
+
@api2 @contexts @contexts-ownership
Scenario: Transfer an inaccessible context
Given table "Table 1 via api v2" with emoji "👋" exists for user "participant1-v2" as "t1" via v2
@@ -584,6 +584,30 @@ Feature: APIv2
Then the reported status is "404"
+ @api2 @rows @custom-filters
+ Scenario: Fetch rows with customFilters parameter containing array values
+ Given table "Tasks" with emoji "📋" exists for user "participant1-v2" as "tasks" via v2
+ Then column "status" exists with following properties
+ | type | selection |
+ | mandatory | 1 |
+ And column "title" exists with following properties
+ | type | text |
+ | subtype | line |
+ | mandatory | 1 |
+ And row exists with following values
+ | status | Task A |
+ And row exists with following values
+ | status | Task B |
+ And row exists with following values
+ | status | Task C |
+ When user "participant1-v2" fetches rows for table "tasks" with customFilters containing array values
+ Then the reported status is "200"
+ And the response contains at least the following rows
+ | title |
+ | Task A |
+ | Task B |
+ | Task C |
+
@api1 @rows
Scenario: Create and modify usergroup row via v1
Given table "Usergroup row check" with emoji "👋" exists for user "participant1-v2" as "base1" via v2
diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php
index a7a4759451..4cceca6a51 100644
--- a/tests/integration/features/bootstrap/FeatureContext.php
+++ b/tests/integration/features/bootstrap/FeatureContext.php
@@ -3162,6 +3162,22 @@ public function setFilterOnView(string $user, string $viewName, TableNode $filte
Assert::assertEquals(200, $this->response->getStatusCode());
}
+ /**
+ * @When user :user fetches rows for table :tableAlias with customFilters containing array values
+ */
+ public function fetchRowsForTableWithCustomFilters(string $user, string $tableAlias): void {
+ $this->setCurrentUser($user);
+ $tableItem = $this->collectionManager->getByAlias('table', $tableAlias);
+
+ // Test with array value in filter (the bug case: value is an array instead of string)
+ $customFilters = json_encode([[['columnId' => -1, 'operator' => 'is-equal', 'value' => [1, 2, 3]]]]);
+
+ $this->sendRequest(
+ 'GET',
+ sprintf('/apps/tables/row/table/%s?customFilters=%s', $tableItem['id'], urlencode($customFilters)),
+ );
+ }
+
/**
* @Then view :viewName has exactly the following rows
*