diff --git a/APIv1.yaml b/APIv1.yaml index 4c23967e2..e5809cde0 100644 --- a/APIv1.yaml +++ b/APIv1.yaml @@ -238,10 +238,10 @@ paths: schema: - $ref: "#/components/schemas/columnProperties" - type: - type: string - required: true + type: string + required: true - subtype: - type: string + type: string responses: '200': @@ -255,6 +255,167 @@ paths: '403': description: Not allowed + /tables/{tableId}/views: + get: + summary: Get a list of views for a given table + description: Shipped with v0.6.0. + tags: + - Views + operationId: listTableViews + security: + - basicAuth: [] + parameters: + - name: tableId + in: path + description: Table ID + required: true + schema: + type: integer + responses: + '200': + description: Json array with view objects + content: + application/json: + schema: + $ref: "#/components/schemas/Views" + '401': + description: Not authorized + '403': + description: Not allowed + '404': + description: Not found + post: + summary: Create a view for given table + description: Shipped with v0.6.0. + tags: + - Views + operationId: createTableView + security: + - basicAuth: [] + parameters: + - name: tableId + in: path + description: Related table ID + required: true + example: 5 + schema: + type: integer + - name: title + in: query + description: Title for the view + required: true + schema: + type: string + - name: emoji + in: query + description: Emoji for the view + required: false + schema: + type: string + responses: + '200': + description: View object + content: + application/json: + schema: + $ref: "#/components/schemas/View" + '401': + description: Not authorized + '403': + description: Not allowed + + /views/{viewId}: + get: + summary: Get a view + description: Shipped with v0.6.0. + tags: + - Views + operationId: getView + security: + - basicAuth: [] + parameters: + - name: viewId + in: path + description: View ID + required: true + schema: + type: integer + responses: + '200': + description: Json view object + content: + application/json: + schema: + $ref: "#/components/schemas/View" + '401': + description: Not authorized + '403': + description: Not allowed + '404': + description: Not found + put: + summary: Update a view + description: Shipped with v0.6.0. + tags: + - Views + operationId: updateView + security: + - basicAuth: [] + parameters: + - name: viewId + in: path + description: ID for the view + required: true + schema: + type: integer + - name: values + in: query + description: object of key-value pairs as json-string + required: true + schema: + $ref: "#/components/schemas/viewProperties" + responses: + '200': + description: Updated view object + content: + application/json: + schema: + $ref: "#/components/schemas/View" + '401': + description: Not authorized + '403': + description: Not allowed + '404': + description: Not found + delete: + summary: Delete a view + description: Shipped with v0.6.0. + tags: + - Views + operationId: deleteView + security: + - basicAuth: [] + parameters: + - name: viewId + in: path + description: ID for the requested view + required: true + schema: + type: integer + responses: + '200': + description: Deleted view object + content: + application/json: + schema: + $ref: "#/components/schemas/View" + '401': + description: Not authorized + '403': + description: Not allowed + '404': + description: Not found + /column/{columnId}: get: summary: Get a column @@ -966,6 +1127,99 @@ components: type: integer datetimeDefault: type: string + Views: + type: array + maxItems: 1000 + items: + $ref: "#/components/schemas/View" + View: + type: object + required: + - id + - title + - tableId + - ownership + properties: + id: + type: integer + tableId: + type: integer + createdBy: + type: string + createdAt: + type: string + lastEditBy: + type: string + lastEditAt: + type: string + description: + type: string + emoji: + type: string + columns: + type: array + items: + type: integer + sort: + $ref: "#/components/schemas/Sorting" + isShared: + type: boolean + onSharePermissions: + type: object + properties: + read: + type: boolean + create: + type: boolean + update: + type: boolean + delete: + type: boolean + manage: + type: boolean + hasShares: + type: boolean + rowsCount: + type: integer + ownerDisplayName: + type: string + filter: + $ref: "#/components/schemas/Filters" + Filters: + type: array + maxItems: 1000 + items: + $ref: "#/components/schemas/FilterGroups" + FilterGroups: + type: array + description: Definition of filter groups that are combined with logical OR. + maxItems: 1000 + items: + $ref: "#/components/schemas/Filter" + Filter: + type: object + description: Definition of filter that are combined with logical AND. + required: + - columnId + - operator + - value + properties: + columnId: + type: integer + operator: + type: string + enum: + - contains + - begins-with + - ends-with + - is-equal + - is-greater-than + - is-greater-than-or-equal + - is-lower-than + - is-lower-than-or-equal + - is-empty + value: + type: string RowsSimple: type: array maxItems: 1000 @@ -1042,3 +1296,38 @@ components: type: integer datetimeDefault: type: string + viewProperties: + type: object + properties: + title: + type: string + required: true + emoji: + type: boolean + required: true + description: + type: string + columns: + type: array + items: + type: integer + sort: + $ref: "#/components/schemas/Sorting" + filter: + $ref: "#/components/schemas/Filters" + Sorting: + type: array + maxItems: 1000 + items: + $ref: "#/components/schemas/Sort" + Sort: + type: object + description: Object with sorting definition + properties: + columnId: + type: integer + mode: + type: string + enum: + - ASC + - DESC diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index c3540286d..d37485972 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -1,5 +1,7 @@ userId = $userId; $this->v1Api = $v1Api; $this->logger = $logger; + $this->l10N = $l10N; } // Tables @@ -124,17 +131,10 @@ public function deleteTable(int $tableId): DataResponse { * @CORS * @NoCSRFRequired */ - public function indexViews(int $tableId, string $keyword = null, int $limit = 100, int $offset = 0): DataResponse { - if ($keyword) { - return $this->handleError(function () use ($keyword, $limit, $offset) { - return $this->viewService->search($keyword, $limit, $offset); - }); - } else { - return $this->handleError(function () use ($tableId) { - return $this->viewService->findAll($this->tableService->find($tableId)); - }); - } - + public function indexViews(int $tableId): DataResponse { + return $this->handleError(function () use ($tableId) { + return $this->viewService->findAll($this->tableService->find($tableId)); + }); } /** @@ -498,9 +498,19 @@ public function indexViewRows(int $viewId, ?int $limit, ?int $offset): DataRespo * @NoAdminRequired * @CORS * @NoCSRFRequired + * + * @param array|string $data */ - public function createRowInView(int $viewId, string $data): DataResponse { - $data = json_decode($data); + public function createRowInView(int $viewId, $data): DataResponse { + if(is_string($data)) { + $data = json_decode($data, true); + } + if(!is_array($data)) { + $this->logger->warning('createRowInView not possible, data array invalid.'); + $message = ['message' => $this->l10N->t('Could not create row.')]; + return new DataResponse($message, Http::STATUS_INTERNAL_SERVER_ERROR); + } + $dataNew = []; foreach ($data as $key => $value) { $dataNew[] = [ @@ -518,9 +528,18 @@ public function createRowInView(int $viewId, string $data): DataResponse { * @NoAdminRequired * @CORS * @NoCSRFRequired + * + * @param array|string $data */ - public function createRowInTable(int $tableId, string $data): DataResponse { - $data = json_decode($data, true); + public function createRowInTable(int $tableId, $data): DataResponse { + if(is_string($data)) { + $data = json_decode($data, true); + } + if(!is_array($data)) { + $this->logger->warning('createRowInTable not possible, data array invalid.'); + $message = ['message' => $this->l10N->t('Could not create row.')]; + return new DataResponse($message, Http::STATUS_INTERNAL_SERVER_ERROR); + } $dataNew = []; foreach ($data as $key => $value) { @@ -550,10 +569,18 @@ public function getRow(int $rowId): DataResponse { * @NoAdminRequired * @CORS * @NoCSRFRequired + * + * @param array|string $data */ - public function updateRow(int $rowId, ?int $viewId, string $data): DataResponse { - $data = json_decode($data, true); - + public function updateRow(int $rowId, ?int $viewId, $data): DataResponse { + if(is_string($data)) { + $data = json_decode($data, true); + } + if(!is_array($data)) { + $this->logger->warning('updateRow not possible, data array invalid.'); + $message = ['message' => $this->l10N->t('Could not update row.')]; + return new DataResponse($message, Http::STATUS_INTERNAL_SERVER_ERROR); + } $dataNew = []; foreach ($data as $key => $value) { $dataNew[] = [ diff --git a/lib/Db/ViewMapper.php b/lib/Db/ViewMapper.php index abf13c9f4..5b3e0f0ff 100644 --- a/lib/Db/ViewMapper.php +++ b/lib/Db/ViewMapper.php @@ -38,7 +38,7 @@ public function find(int $id, bool $skipEnhancement = false): View { ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); $view = $this->findEntity($qb); if(!$skipEnhancement) { - $this->enhanceByOwnership($view); + $this->enhanceOwnership($view); } return $view; } @@ -58,7 +58,7 @@ public function findAll(?int $tableId = null): array { } $views = $this->findEntities($qb); foreach($views as $view) { - $this->enhanceByOwnership($view); + $this->enhanceOwnership($view); } return $views; } @@ -119,7 +119,7 @@ public function search(string $term = null, ?string $userId = null, ?int $limit $views = $this->findEntities($qb); foreach($views as $view) { - $this->enhanceByOwnership($view); + $this->enhanceOwnership($view); } return $views; } @@ -129,7 +129,7 @@ public function search(string $term = null, ?string $userId = null, ?int $limit * @return void * @throws InternalError */ - private function enhanceByOwnership(View $view): void { + private function enhanceOwnership(View $view): void { try { $view->setOwnership($this->tableMapper->findOwnership($view->getTableId())); } catch (Exception $e) { diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index 3de353db1..b00a940f6 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -1,5 +1,7 @@ $value) { - if (!in_array($key, $updatebleColumns)) { - throw new InternalError('Column '.$key.' can not be updated.'); + if (!in_array($key, $updatableParameter)) { + throw new InternalError('View parameter '.$key.' can not be updated.'); } $setterMethod = 'set'.ucfirst($key); $view->$setterMethod($value); diff --git a/tests/integration/features/api/tablesapi.feature b/tests/integration/features/api/tablesapi.feature index 1380f52f8..a76c19e90 100644 --- a/tests/integration/features/api/tablesapi.feature +++ b/tests/integration/features/api/tablesapi.feature @@ -11,7 +11,7 @@ Feature: api/tablesapi | Tutorial | Scenario: User creates, rename and delete a table - Given table "my new awesome table" with emoji "🤓" exists for user "participant1" + Given table "my new awesome table" with emoji "🤓" exists for user "participant1" as "base1" Then user "participant1" has the following tables | my new awesome table | Then user "participant1" updates table with keyword "awesome" set title "renamed table" and optional emoji "🍓" @@ -21,7 +21,7 @@ Feature: api/tablesapi | Tutorial | Scenario: Table sharing with a user - Given table "Ready to share" with emoji "🥪" exists for user "participant1" + Given table "Ready to share" with emoji "🥪" exists for user "participant1" as "base1" Then user "participant1" shares table with user "participant2" Then user "participant2" has the following permissions | read | 1 | @@ -46,7 +46,7 @@ Feature: api/tablesapi | Tutorial | Scenario: Table sharing with a group - Given table "Ready to share" with emoji "🥪" exists for user "participant1" + Given table "Ready to share" with emoji "🥪" exists for user "participant1" as "base1" Then user "participant1" shares table with group "phoenix" Then user "participant2" has the following tables | Tutorial | Ready to share | @@ -57,7 +57,7 @@ Feature: api/tablesapi | Tutorial | Scenario: Create and check columns - Given table "Column test" with emoji "🥶" exists for user "participant1" + Given table "Column test" with emoji "🥶" exists for user "participant1" as "base1" Then table has at least following columns Then column "First column" exists with following properties | type | text | @@ -100,7 +100,7 @@ Feature: api/tablesapi Then user "participant1" deletes table with keyword "Column test" Scenario: Create, modify and delete rows - Given table "Rows check" with emoji "👨🏻‍💻" exists for user "participant1" + Given table "Rows check" with emoji "👨🏻‍💻" exists for user "participant1" as "base1" Then column "one" exists with following properties | type | text | | subtype | line | @@ -134,7 +134,40 @@ Feature: api/tablesapi Then user deletes last created row Then user "participant1" deletes table with keyword "Rows check" - + Scenario: Create, modify and delete rows (legacy interface) + Given table "Rows check legacy" with emoji "👨🏻‍💻" exists for user "participant1" as "base1" + Then column "one" exists with following properties + | type | text | + | subtype | line | + | mandatory | 0 | + | description | This is a description! | + Then column "two" exists with following properties + | type | number | + | mandatory | 1 | + | numberDefault | 10 | + | description | This is a description! | + Then column "three" exists with following properties + | type | selection | + | subtype | check | + | mandatory | 1 | + | description | This is a description! | + Then column "four" exists with following properties + | type | datetime | + | subtype | date | + | mandatory | 0 | + | description | This is a description! | + Then row exists with following values via legacy interface + | one | AHA | + | two | 88 | + | three | 1 | + | four | 2023-12-24 | + Then set following values for last created row via legacy interface + | one | AHA! | + | two | 99 | + | three | 0 | + | four | 2020-02-04 | + Then user deletes last created row + Then user "participant1" deletes table with keyword "Rows check" Scenario: Import csv table @@ -142,7 +175,7 @@ Feature: api/tablesapi | Col1 | Col2 | Col3 | num | emoji | special | | Val1 | Val2 | Val3 | 1 | 💙 | Ä | | great | news | here | 99 | ⚠️ | Ö | - Given table "Import test" with emoji "👨🏻‍💻" exists for user "participant1" + Given table "Import test" with emoji "👨🏻‍💻" exists for user "participant1" as "base1" When user imports file "/import.csv" into last created table Then import results have the following data | found_columns_count | 6 | @@ -160,3 +193,18 @@ Feature: api/tablesapi | Col1 | Col2 | Col3 | num | emoji | special | | Val1 | Val2 | Val3 | 1 | 💙 | Ä | | great | news | here | 99 | ⚠️ | Ö | + + Scenario: Create, edit and delete views + Given table "View test" with emoji "👨🏻‍💻" exists for user "participant1" as "view-test" + # Then print register + Then table "view-test" has the following views for user "participant1" + # Then print register + When user "participant1" create view "first view" with emoji "⚡️" for "view-test" as "first-view" + Then table "view-test" has the following views for user "participant1" + | first view | + # Then print register + When user "participant1" update view "first-view" with title "updated first view" and emoji "💾" + Then table "view-test" has the following views for user "participant1" + | updated first view | + When user "participant1" deletes view "first-view" + Then table "view-test" has the following views for user "participant1" diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 122557cc5..8f2a252ae 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -62,6 +62,14 @@ class FeatureContext implements Context, SnippetAcceptingContext { private ?array $tableColumns = []; private ?array $importResult = null; + // we need some ids to reuse it in some contexts, + // but we can not return them and reuse it in the scenarios, + // that's why we hold a kind of register here + // structure: $name -> item-id + // example for a table: 'test-table' -> 5 + private array $tableIds = []; + private array $viewIds = []; + use CommandLineTrait; /** @@ -158,6 +166,18 @@ public function checkImportResults(TableNode $table): void { } } + /** + * @Then print register + * + */ + public function printRegister(): void { + echo "REGISTER ========================\n"; + echo "Tables --------------------\n"; + print_r($this->tableIds); + echo "Views --------------------\n"; + print_r($this->viewIds); + } + /** * @Then table contains at least following rows * @@ -230,13 +250,88 @@ public function userTables(string $user, TableNode $body = null): void { } /** - * @Given table :table with emoji :emoji exists for user :user + * @Then table :tableName has the following views for user :user + * + * @param string $tableName + * @param string $user + * @param TableNode|null $body + */ + public function tableViews(string $tableName, string $user, TableNode $body = null): void { + $this->setCurrentUser($user); + + $this->sendRequest( + 'GET', + '/apps/tables/api/1/tables/'.$this->tableIds[$tableName].'/views' + ); + + $data = $this->getDataFromResponse($this->response); + + Assert::assertEquals(200, $this->response->getStatusCode()); + + // check if views are empty + if ($body === null) { + Assert::assertCount(0, $data); + return; + } + + // check if given view exists + $titles = []; + foreach ($data as $d) { + $titles[] = $d['title']; + } + foreach ($body->getRows()[0] as $viewTitle) { + Assert::assertTrue(in_array($viewTitle, $titles, true)); + } + } + + /** + * @Given user :user create view :title with emoji :emoji for :tableName as :viewName * * @param string $user * @param string $title + * @param string $tableName + * @param string $viewName * @param string|null $emoji */ - public function createTable(string $user, string $title, string $emoji = null): void { + public function createView(string $user, string $title, string $tableName, string $viewName, string $emoji = null): void { + $this->setCurrentUser($user); + $this->sendRequest( + 'POST', + '/apps/tables/api/1/tables/'.$this->tableIds[$tableName].'/views', + [ + 'title' => $title, + 'emoji' => $emoji + ] + ); + + $newItem = $this->getDataFromResponse($this->response); + $this->viewIds[$viewName] = $newItem['id']; + + Assert::assertEquals(200, $this->response->getStatusCode()); + Assert::assertEquals($newItem['title'], $title); + Assert::assertEquals($newItem['emoji'], $emoji); + + $this->sendRequest( + 'GET', + '/apps/tables/api/1/views/'.$newItem['id'], + ); + + $itemToVerify = $this->getDataFromResponse($this->response); + + Assert::assertEquals(200, $this->response->getStatusCode()); + Assert::assertEquals($itemToVerify['title'], $title); + Assert::assertEquals($itemToVerify['emoji'], $emoji); + } + + /** + * @Given table :table with emoji :emoji exists for user :user as :tableName + * + * @param string $user + * @param string $title + * @param string $tableName + * @param string|null $emoji + */ + public function createTable(string $user, string $title, string $tableName, string $emoji = null): void { $this->setCurrentUser($user); $this->sendRequest( 'POST', @@ -249,6 +344,7 @@ public function createTable(string $user, string $title, string $emoji = null): $newTable = $this->getDataFromResponse($this->response); $this->tableId = $newTable['id']; + $this->tableIds[$tableName] = $newTable['id']; Assert::assertEquals(200, $this->response->getStatusCode()); Assert::assertEquals($newTable['title'], $title); @@ -328,6 +424,71 @@ public function updateTable(string $user, string $title, ?string $emoji, string Assert::assertEquals($tableToVerify['ownership'], $user); } + /** + * @When user :user update view :viewName with title :title and emoji :emoji + * + * @param string $user + * @param string $viewName + * @param string $title + * @param string|null $emoji + */ + public function updateView(string $user, string $viewName, string $title, ?string $emoji): void { + $this->setCurrentUser($user); + + $data = ['title' => $title]; + if ($emoji !== null) { + $data['emoji'] = $emoji; + } + + $this->sendRequest( + 'PUT', + '/apps/tables/api/1/views/'.$this->viewIds[$viewName], + [ 'data' => $data ] + ); + + $updatedItem = $this->getDataFromResponse($this->response); + + Assert::assertEquals(200, $this->response->getStatusCode()); + Assert::assertEquals($updatedItem['title'], $title); + Assert::assertEquals($updatedItem['emoji'], $emoji); + + $this->sendRequest( + 'GET', + '/apps/tables/api/1/views/'.$updatedItem['id'], + ); + + $itemToVerify = $this->getDataFromResponse($this->response); + Assert::assertEquals(200, $this->response->getStatusCode()); + Assert::assertEquals($itemToVerify['title'], $title); + Assert::assertEquals($itemToVerify['emoji'], $emoji); + } + + /** + * @When user :user deletes view :viewName + * + * @param string $user + * @param string $viewName + */ + public function deleteView(string $user, string $viewName): void { + $this->setCurrentUser($user); + + $this->sendRequest( + 'DELETE', + '/apps/tables/api/1/views/'.$this->viewIds[$viewName] + ); + $deletedItem = $this->getDataFromResponse($this->response); + + Assert::assertEquals(200, $this->response->getStatusCode()); + + $this->sendRequest( + 'GET', + '/apps/tables/api/1/tables/'.$deletedItem['id'], + ); + Assert::assertEquals(404, $this->response->getStatusCode()); + + unset($this->viewIds[$viewName]); + } + /** * @Then user :user deletes table with keyword :keyword * @@ -658,7 +819,43 @@ public function createRow(TableNode $properties = null): void { $props[$columnId] = $row[1]; } - print_r($props); + $this->sendRequest( + 'POST', + '/apps/tables/api/1/tables/'.$this->tableId.'/rows', + ['data' => $props] + ); + + $newRow = $this->getDataFromResponse($this->response); + $this->rowId = $newRow['id']; + + Assert::assertEquals(200, $this->response->getStatusCode()); + foreach ($newRow['data'] as $cell) { + Assert::assertEquals($props[$cell['columnId']], $cell['value']); + } + + $this->sendRequest( + 'GET', + '/apps/tables/api/1/rows/'.$newRow['id'], + ); + + $rowToVerify = $this->getDataFromResponse($this->response); + Assert::assertEquals(200, $this->response->getStatusCode()); + foreach ($rowToVerify['data'] as $cell) { + Assert::assertEquals($props[$cell['columnId']], $cell['value']); + } + } + + /** + * @Then row exists with following values via legacy interface + * + * @param TableNode|null $properties + */ + public function createRowLegacy(TableNode $properties = null): void { + $props = []; + foreach ($properties->getRows() as $row) { + $columnId = $this->tableColumns[$row[0]]; + $props[$columnId] = $row[1]; + } $this->sendRequest( 'POST', @@ -667,7 +864,6 @@ public function createRow(TableNode $properties = null): void { ); $newRow = $this->getDataFromResponse($this->response); - // var_dump($newRow); $this->rowId = $newRow['id']; Assert::assertEquals(200, $this->response->getStatusCode()); @@ -722,7 +918,7 @@ public function updateRow(TableNode $properties = null): void { $this->sendRequest( 'PUT', '/apps/tables/api/1/rows/'.$this->rowId, - ['data' => json_encode($props)] + ['data' => $props] ); $row = $this->getDataFromResponse($this->response); @@ -744,12 +940,42 @@ public function updateRow(TableNode $properties = null): void { } } + /** + * @Then set following values for last created row via legacy interface + * + * @param TableNode|null $properties + */ + public function updateRowLegacy(TableNode $properties = null): void { + $props = []; + foreach ($properties->getRows() as $row) { + $columnId = $this->tableColumns[$row[0]]; + $props[$columnId] = $row[1]; + } + $this->sendRequest( + 'PUT', + '/apps/tables/api/1/rows/'.$this->rowId, + ['data' => json_encode($props)] + ); + $row = $this->getDataFromResponse($this->response); + Assert::assertEquals(200, $this->response->getStatusCode()); + foreach ($row['data'] as $cell) { + Assert::assertEquals($props[$cell['columnId']], $cell['value']); + } + $this->sendRequest( + 'GET', + '/apps/tables/api/1/rows/'.$row['id'], + ); - + $rowToVerify = $this->getDataFromResponse($this->response); + Assert::assertEquals(200, $this->response->getStatusCode()); + foreach ($rowToVerify['data'] as $cell) { + Assert::assertEquals($props[$cell['columnId']], $cell['value']); + } + } /* * User management