You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/06/23 17:38:17 UTC
(texera) branch gh-readonly-queue/main/pr-5893-8803d084cd68fc98c06c782aa7a56450e7ddc9ee created (now c0700ff24b)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-5893-8803d084cd68fc98c06c782aa7a56450e7ddc9ee
in repository https://gitbox.apache.org/repos/asf/texera.git
at c0700ff24b feat: add user feedback dashboard and admin review (#5893)
This branch includes the following new commits:
new c0700ff24b feat: add user feedback dashboard and admin review (#5893)
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(texera) 01/01: feat: add user feedback dashboard and admin review (#5893)
Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5893-8803d084cd68fc98c06c782aa7a56450e7ddc9ee
in repository https://gitbox.apache.org/repos/asf/texera.git
commit c0700ff24b16fb8779ca57ff161298b42a6556f0
Author: ali risheh <al...@gmail.com>
AuthorDate: Tue Jun 23 10:37:55 2026 -0700
feat: add user feedback dashboard and admin review (#5893)
### What changes were proposed in this PR?
This PR adds a **user feedback** feature to the dashboard.
- A **"Feedback"** item is added to the left sidebar, immediately after
**"About"**, and is shown **only to logged-in users**. It routes to
`/user/feedback` (behind the existing auth guard).
- The feedback page lets a user **submit a free-text message** and shows
a **table of their previously submitted feedback** (newest first).
- The admin user list at `/dashboard/admin/user` gains a **"Feedbacks"**
column next to **Quota**: a message icon that is **disabled when the
user has no feedback** and **enabled with the feedback count shown as a
badge** when they do. Clicking it opens that user's feedback in a modal
(the same component reused in read-only mode).
Backend:
- New `feedback` table (added to `sql/texera_ddl.sql`, migration
`sql/updates/25.sql`, and registered in `sql/changelog.xml`).
- New `FeedbackResource`:
- `POST /api/feedback` and `GET /api/feedback` — submit / list own
feedback (any logged-in user).
- `GET /api/feedback/counts` and `GET /api/feedback/user?user_id=` —
per-user counts and per-user listing (**admin only**).
This implements the simple feedback mechanism (Option 2) agreed in the
design discussion.
### Any related issues, documentation, discussions?
Closes #5894
Proposed and agreed in discussion #5759.
### How was this PR tested?
**Backend unit tests** — `FeedbackResourceSpec` (10 cases, using the
embedded `MockTexeraDB`): persistence, newest-first ordering, whitespace
trimming, empty/null rejection, per-user isolation, admin counts, and
admin per-user listing.
```
sbt "WorkflowExecutionService / testOnly org.apache.texera.web.resource.FeedbackResourceSpec"
# Tests: succeeded 10, failed 0
```
**Frontend unit tests** — `FeedbackService` (4), `FeedbackComponent` (5,
both page and admin-modal modes), and the existing `AdminUserComponent`
spec still passes with the new dependency.
```
ng test --watch=false --include="**/feedback*.spec.ts" --include="**/admin-user*.spec.ts"
# Test Files 3 passed (3) | Tests 10 passed (10)
```
**Manual end-to-end** (local services + Postgres), logged in as the
default admin:
```
POST /api/feedback -> 204
GET /api/feedback -> 200 [{"fid":1,"uid":1,"message":"...","creationTime":...}]
GET /api/feedback/counts -> 200 [{"uid":1,"count":1}]
GET /api/feedback/user?... -> 200
POST /api/feedback (empty) -> 400 "feedback message cannot be empty"
```
UI flow verified locally: sidebar entry appears only when logged in,
submit + own-feedback table work, and the admin column badge/disabled
state and modal behave as described. _Reviewer note: before/after
screenshots of the sidebar item, feedback page, and admin column to be
attached._
<img width="1847" height="965" alt="Screenshot from 2026-06-22 14-48-07"
src="https://github.com/user-attachments/assets/aaf972e0-e39f-48aa-af4f-859385867935"
/>
<img width="1847" height="965" alt="Screenshot from 2026-06-22 14-47-40"
src="https://github.com/user-attachments/assets/8d223be0-b196-408b-bb77-4e97d6102807"
/>
<img width="1847" height="965" alt="Screenshot from 2026-06-22 14-47-07"
src="https://github.com/user-attachments/assets/49580b32-8307-43f8-a9b1-65cd6f54577f"
/>
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.8)
Co-authored-by: Claude Opus 4.8 (1M context) <no...@anthropic.com>
---
.../apache/texera/web/TexeraWebApplication.scala | 1 +
.../texera/web/resource/FeedbackResource.scala | 132 +++++++++++++++++
.../texera/web/resource/FeedbackResourceSpec.scala | 156 +++++++++++++++++++++
frontend/src/app/app-routing.constant.ts | 1 +
frontend/src/app/app-routing.module.ts | 5 +
.../component/admin/user/admin-user.component.html | 19 +++
.../component/admin/user/admin-user.component.ts | 32 ++++-
.../dashboard/component/dashboard.component.html | 12 ++
.../component/dashboard.component.spec.ts | 4 +-
.../app/dashboard/component/dashboard.component.ts | 2 +
.../user/feedback/feedback.component.html | 73 ++++++++++
.../user/feedback/feedback.component.scss | 47 +++++++
.../user/feedback/feedback.component.spec.ts | 123 ++++++++++++++++
.../component/user/feedback/feedback.component.ts | 129 +++++++++++++++++
.../service/user/feedback/feedback.service.spec.ts | 84 +++++++++++
.../service/user/feedback/feedback.service.ts | 56 ++++++++
.../src/app/dashboard/type/feedback.interface.ts | 34 +++++
sql/changelog.xml | 5 +
sql/texera_ddl.sql | 10 ++
sql/updates/25.sql | 38 +++++
20 files changed, 960 insertions(+), 3 deletions(-)
diff --git a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
index c93f75fe75..b4852abfb1 100644
--- a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
+++ b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
@@ -144,6 +144,7 @@ class TexeraWebApplication
environment.jersey.register(classOf[AuthResource])
environment.jersey.register(classOf[GoogleAuthResource])
environment.jersey.register(classOf[UserConfigResource])
+ environment.jersey.register(classOf[FeedbackResource])
environment.jersey.register(classOf[AdminUserResource])
environment.jersey.register(classOf[PublicProjectResource])
environment.jersey.register(classOf[WorkflowAccessResource])
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/FeedbackResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/FeedbackResource.scala
new file mode 100644
index 0000000000..8fc99b22c6
--- /dev/null
+++ b/amber/src/main/scala/org/apache/texera/web/resource/FeedbackResource.scala
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.web.resource
+
+import io.dropwizard.auth.Auth
+import org.apache.texera.auth.SessionUser
+import org.apache.texera.dao.SqlServer
+import org.apache.texera.dao.jooq.generated.Tables.FEEDBACK
+import org.apache.texera.dao.jooq.generated.tables.pojos.Feedback
+import org.apache.texera.web.resource.FeedbackResource.{
+ FeedbackCount,
+ FeedbackEntry,
+ SubmitFeedbackRequest
+}
+import org.jooq.impl.DSL
+
+import javax.annotation.security.RolesAllowed
+import javax.ws.rs._
+import javax.ws.rs.core._
+import scala.jdk.CollectionConverters._
+
+object FeedbackResource {
+ case class SubmitFeedbackRequest(message: String)
+ case class FeedbackEntry(fid: Integer, uid: Integer, message: String, creationTime: Long)
+ case class FeedbackCount(uid: Integer, count: Integer)
+}
+
+@Path("/feedback")
+@RolesAllowed(Array("REGULAR", "ADMIN"))
+class FeedbackResource {
+
+ /**
+ * Submit a new feedback message for the currently logged-in user. The fid
+ * (SERIAL) and creation_time (DEFAULT) columns are populated by the database.
+ */
+ @POST
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ def submitFeedback(request: SubmitFeedbackRequest, @Auth sessionUser: SessionUser): Unit = {
+ val message = Option(request).flatMap(r => Option(r.message)).map(_.trim).getOrElse("")
+ if (message.isEmpty) {
+ throw new BadRequestException("feedback message cannot be empty")
+ }
+ SqlServer
+ .getInstance()
+ .createDSLContext()
+ .insertInto(FEEDBACK, FEEDBACK.UID, FEEDBACK.MESSAGE)
+ .values(sessionUser.getUid, message)
+ .execute()
+ }
+
+ /**
+ * List the feedback submitted by the currently logged-in user, newest first.
+ */
+ @GET
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def listMyFeedback(@Auth sessionUser: SessionUser): List[FeedbackEntry] = {
+ fetchFeedbackByUid(sessionUser.getUid)
+ }
+
+ /**
+ * Admin only: number of feedback messages per user, for users who have
+ * submitted at least one. Users with zero feedback are omitted.
+ */
+ @GET
+ @Path("/counts")
+ @RolesAllowed(Array("ADMIN"))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def feedbackCounts(): List[FeedbackCount] = {
+ val countField = DSL.count()
+ SqlServer
+ .getInstance()
+ .createDSLContext()
+ .select(FEEDBACK.UID, countField)
+ .from(FEEDBACK)
+ .groupBy(FEEDBACK.UID)
+ .fetch()
+ .asScala
+ .map(record => FeedbackCount(record.get(FEEDBACK.UID), record.get(countField)))
+ .toList
+ }
+
+ /**
+ * Admin only: list the feedback submitted by a specific user, newest first.
+ */
+ @GET
+ @Path("/user")
+ @RolesAllowed(Array("ADMIN"))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def listUserFeedback(@QueryParam("user_id") userId: Integer): List[FeedbackEntry] = {
+ if (userId == null) {
+ throw new BadRequestException("user_id is required")
+ }
+ fetchFeedbackByUid(userId)
+ }
+
+ private def fetchFeedbackByUid(uid: Integer): List[FeedbackEntry] = {
+ SqlServer
+ .getInstance()
+ .createDSLContext()
+ .selectFrom(FEEDBACK)
+ .where(FEEDBACK.UID.eq(uid))
+ .orderBy(FEEDBACK.CREATION_TIME.desc())
+ .fetchInto(classOf[Feedback])
+ .asScala
+ .map(feedback =>
+ FeedbackEntry(
+ feedback.getFid,
+ feedback.getUid,
+ feedback.getMessage,
+ feedback.getCreationTime.getTime
+ )
+ )
+ .toList
+ }
+}
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/FeedbackResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/FeedbackResourceSpec.scala
new file mode 100644
index 0000000000..9b081f7efa
--- /dev/null
+++ b/amber/src/test/scala/org/apache/texera/web/resource/FeedbackResourceSpec.scala
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.web.resource
+
+import org.apache.texera.auth.SessionUser
+import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.Tables.FEEDBACK
+import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
+import org.apache.texera.dao.jooq.generated.tables.daos.UserDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.User
+import org.apache.texera.web.resource.FeedbackResource.SubmitFeedbackRequest
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import java.util.UUID
+import javax.ws.rs.BadRequestException
+
+class FeedbackResourceSpec
+ extends AnyFlatSpec
+ with Matchers
+ with BeforeAndAfterAll
+ with MockTexeraDB {
+
+ private val testUid = 9000 + scala.util.Random.nextInt(1000)
+ private val otherUid = testUid + 1
+ private var sessionUser: SessionUser = _
+ private var otherSessionUser: SessionUser = _
+ private val resource = new FeedbackResource
+
+ private def makeUser(uid: Int, name: String): User = {
+ val user = new User
+ user.setUid(uid)
+ user.setName(name)
+ user.setEmail(s"user_${UUID.randomUUID()}@example.com")
+ user.setPassword("password")
+ user.setRole(UserRoleEnum.REGULAR)
+ user
+ }
+
+ override protected def beforeAll(): Unit = {
+ initializeDBAndReplaceDSLContext()
+ val userDao = new UserDao(getDSLContext.configuration())
+ val testUser = makeUser(testUid, "feedback_spec_user")
+ val otherUser = makeUser(otherUid, "feedback_spec_other_user")
+ userDao.insert(testUser)
+ userDao.insert(otherUser)
+ sessionUser = new SessionUser(testUser)
+ otherSessionUser = new SessionUser(otherUser)
+ }
+
+ override protected def afterAll(): Unit = shutdownDB()
+
+ private def clearFeedback(): Unit = {
+ getDSLContext.deleteFrom(FEEDBACK).execute()
+ }
+
+ "FeedbackResource" should "persist a submitted feedback and return it for the same user" in {
+ clearFeedback()
+ resource.submitFeedback(SubmitFeedbackRequest("the editor is great"), sessionUser)
+
+ val feedback = resource.listMyFeedback(sessionUser)
+ feedback should have size 1
+ feedback.head.message shouldBe "the editor is great"
+ feedback.head.uid shouldBe testUid
+ feedback.head.fid should not be null
+ feedback.head.creationTime should be > 0L
+ }
+
+ it should "return feedback newest first" in {
+ clearFeedback()
+ resource.submitFeedback(SubmitFeedbackRequest("first message"), sessionUser)
+ Thread.sleep(1000) // creation_time has 1-second resolution
+ resource.submitFeedback(SubmitFeedbackRequest("second message"), sessionUser)
+
+ val messages = resource.listMyFeedback(sessionUser).map(_.message)
+ messages shouldBe List("second message", "first message")
+ }
+
+ it should "trim surrounding whitespace from the feedback message" in {
+ clearFeedback()
+ resource.submitFeedback(SubmitFeedbackRequest(" padded message "), sessionUser)
+ resource.listMyFeedback(sessionUser).head.message shouldBe "padded message"
+ }
+
+ it should "reject an empty or whitespace-only feedback message" in {
+ clearFeedback()
+ an[BadRequestException] should be thrownBy
+ resource.submitFeedback(SubmitFeedbackRequest(" "), sessionUser)
+ an[BadRequestException] should be thrownBy
+ resource.submitFeedback(SubmitFeedbackRequest(""), sessionUser)
+ resource.listMyFeedback(sessionUser) shouldBe empty
+ }
+
+ it should "reject a null message body" in {
+ clearFeedback()
+ an[BadRequestException] should be thrownBy
+ resource.submitFeedback(SubmitFeedbackRequest(null), sessionUser)
+ }
+
+ it should "isolate feedback between users" in {
+ clearFeedback()
+ resource.submitFeedback(SubmitFeedbackRequest("from test user"), sessionUser)
+ resource.submitFeedback(SubmitFeedbackRequest("from other user a"), otherSessionUser)
+ resource.submitFeedback(SubmitFeedbackRequest("from other user b"), otherSessionUser)
+
+ resource.listMyFeedback(sessionUser).map(_.message) shouldBe List("from test user")
+ resource.listMyFeedback(otherSessionUser) should have size 2
+ }
+
+ "feedbackCounts" should "report per-user counts only for users with feedback" in {
+ clearFeedback()
+ resource.submitFeedback(SubmitFeedbackRequest("a"), sessionUser)
+ resource.submitFeedback(SubmitFeedbackRequest("b"), sessionUser)
+ resource.submitFeedback(SubmitFeedbackRequest("c"), otherSessionUser)
+
+ val counts = resource.feedbackCounts().map(c => c.uid.intValue() -> c.count.intValue()).toMap
+ counts(testUid) shouldBe 2
+ counts(otherUid) shouldBe 1
+ }
+
+ it should "return an empty list when nobody has submitted feedback" in {
+ clearFeedback()
+ resource.feedbackCounts() shouldBe empty
+ }
+
+ "listUserFeedback" should "return a specific user's feedback for admins" in {
+ clearFeedback()
+ resource.submitFeedback(SubmitFeedbackRequest("target user feedback"), otherSessionUser)
+ resource.submitFeedback(SubmitFeedbackRequest("noise"), sessionUser)
+
+ val feedback = resource.listUserFeedback(otherUid)
+ feedback.map(_.message) shouldBe List("target user feedback")
+ }
+
+ it should "reject a missing user_id" in {
+ an[BadRequestException] should be thrownBy resource.listUserFeedback(null)
+ }
+}
diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts
index e0b2c9eab0..f5a0130039 100644
--- a/frontend/src/app/app-routing.constant.ts
+++ b/frontend/src/app/app-routing.constant.ts
@@ -38,6 +38,7 @@ export const USER_COMPUTING_UNIT = `${USER}/compute`;
export const USER_PYTHON_VENV = `${USER}/python-venv`;
export const USER_QUOTA = `${USER}/quota`;
export const USER_DISCUSSION = `${USER}/discussion`;
+export const USER_FEEDBACK = `${USER}/feedback`;
export const ADMIN = "/admin";
export const ADMIN_USER = `${ADMIN}/user`;
diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts
index 78ccf0232c..58f9014300 100644
--- a/frontend/src/app/app-routing.module.ts
+++ b/frontend/src/app/app-routing.module.ts
@@ -34,6 +34,7 @@ import { AdminExecutionComponent } from "./dashboard/component/admin/execution/a
import { AdminGuardService } from "./dashboard/service/admin/guard/admin-guard.service";
import { SearchComponent } from "./dashboard/component/user/search/search.component";
import { FlarumComponent } from "./dashboard/component/user/flarum/flarum.component";
+import { FeedbackComponent } from "./dashboard/component/user/feedback/feedback.component";
import { AdminGmailComponent } from "./dashboard/component/admin/gmail/admin-gmail.component";
import { DatasetDetailComponent } from "./dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component";
import { UserDatasetComponent } from "./dashboard/component/user/user-dataset/user-dataset.component";
@@ -141,6 +142,10 @@ routes.push({
path: "discussion",
component: FlarumComponent,
},
+ {
+ path: "feedback",
+ component: FeedbackComponent,
+ },
],
},
{
diff --git a/frontend/src/app/dashboard/component/admin/user/admin-user.component.html b/frontend/src/app/dashboard/component/admin/user/admin-user.component.html
index f39ad7b20a..4070cb9308 100644
--- a/frontend/src/app/dashboard/component/admin/user/admin-user.component.html
+++ b/frontend/src/app/dashboard/component/admin/user/admin-user.component.html
@@ -106,6 +106,7 @@
User Role
</th>
<th>Quota</th>
+ <th>Feedbacks</th>
<th
[nzSortFn]="sortByAccountCreation"
[nzSortDirections]="['ascend', 'descend']">
@@ -314,6 +315,24 @@
nzType="dashboard"></i>
</button>
</td>
+ <td>
+ <button
+ (click)="clickToViewFeedbacks(user.uid)"
+ [disabled]="getFeedbackCount(user.uid) === 0"
+ nz-button
+ [nz-tooltip]="getFeedbackCount(user.uid) === 0 ? 'no feedback submitted' : 'view feedback'"
+ type="button">
+ <nz-badge
+ [nzCount]="getFeedbackCount(user.uid)"
+ [nzOverflowCount]="99"
+ nzSize="small">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="message"></i>
+ </nz-badge>
+ </button>
+ </td>
<td>
<ng-container *ngIf="getAccountCreation(user) as ac; else noAC">
{{ ac | date:'MM/dd/y, h:mm a' }}
diff --git a/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts b/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts
index 090032da2a..0d7f620480 100644
--- a/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts
+++ b/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts
@@ -37,6 +37,9 @@ import { AdminUserService } from "../../../service/admin/user/admin-user.service
import { MilliSecond, Role, User } from "../../../../common/type/user";
import { UserService } from "../../../../common/service/user/user.service";
import { UserQuotaComponent } from "../../user/user-quota/user-quota.component";
+import { FeedbackComponent } from "../../user/feedback/feedback.component";
+import { FeedbackService } from "../../../service/user/feedback/feedback.service";
+import { NzBadgeComponent } from "ng-zorro-antd/badge";
import { GuiConfigService } from "../../../../common/service/gui-config.service";
import { replaceOneImmutable } from "../../../../common/util/array-utils";
import { NzCardComponent } from "ng-zorro-antd/card";
@@ -82,6 +85,7 @@ import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
NzSelectComponent,
NzOptionComponent,
NzTooltipDirective,
+ NzBadgeComponent,
DatePipe,
],
})
@@ -101,6 +105,7 @@ export class AdminUserComponent implements OnInit {
commentSearchVisible = false;
listOfDisplayUser = [...this.userList];
currentUid: number | undefined = 0;
+ feedbackCounts = new Map<number, number>();
@ViewChild("nameInput") nameInputRef?: ElementRef<HTMLInputElement>;
@ViewChild("emailInput") emailInputRef?: ElementRef<HTMLInputElement>;
@@ -111,7 +116,8 @@ export class AdminUserComponent implements OnInit {
private userService: UserService,
private modalService: NzModalService,
private messageService: NzMessageService,
- private config: GuiConfigService
+ private config: GuiConfigService,
+ private feedbackService: FeedbackService
) {
this.currentUid = this.userService.getCurrentUser()?.uid;
}
@@ -124,6 +130,30 @@ export class AdminUserComponent implements OnInit {
this.userList = userList;
this.reset();
});
+ this.loadFeedbackCounts();
+ }
+
+ loadFeedbackCounts(): void {
+ this.feedbackService
+ .getFeedbackCounts()
+ .pipe(untilDestroyed(this))
+ .subscribe(counts => {
+ this.feedbackCounts = new Map(counts.map(c => [c.uid, c.count]));
+ });
+ }
+
+ getFeedbackCount(uid: number): number {
+ return this.feedbackCounts.get(uid) ?? 0;
+ }
+
+ clickToViewFeedbacks(uid: number): void {
+ this.modalService.create({
+ nzContent: FeedbackComponent,
+ nzData: { uid: uid },
+ nzFooter: null,
+ nzWidth: "60%",
+ nzCentered: true,
+ });
}
public updateRole(user: User, role: Role): void {
diff --git a/frontend/src/app/dashboard/component/dashboard.component.html b/frontend/src/app/dashboard/component/dashboard.component.html
index c01def869b..f9336600bf 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.html
+++ b/frontend/src/app/dashboard/component/dashboard.component.html
@@ -211,6 +211,18 @@
nzType="info-circle"></span>
<span>About</span>
</li>
+
+ <li
+ *ngIf="isLogin"
+ nz-menu-item
+ nz-tooltip
+ nzTooltipPlacement="right"
+ [routerLink]="USER_FEEDBACK">
+ <span
+ nz-icon
+ nzType="message"></span>
+ <span>Feedback</span>
+ </li>
</ul>
<span id="build-number">Build: {{ buildNumber }}</span>
<span
diff --git a/frontend/src/app/dashboard/component/dashboard.component.spec.ts b/frontend/src/app/dashboard/component/dashboard.component.spec.ts
index 10352e3b57..cad7ed91be 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.spec.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.spec.ts
@@ -283,7 +283,7 @@ describe("DashboardComponent", () => {
};
fixture.detectChanges();
- // 7 "Your Work" links (incl. Python Venvs) + 4 admin links + 1 about link = 12
- expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(12);
+ // 7 "Your Work" links (incl. Python Venvs) + 4 admin links + 1 about link + 1 feedback link = 13
+ expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(13);
});
});
diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts b/frontend/src/app/dashboard/component/dashboard.component.ts
index 01c05b0e52..88dc04bb69 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.ts
@@ -41,6 +41,7 @@ import {
USER_PYTHON_VENV,
USER_QUOTA,
USER_WORKFLOW,
+ USER_FEEDBACK,
} from "../../app-routing.constant";
import { Version } from "../../../environments/version";
import { SidebarTabs } from "../../common/type/gui-config";
@@ -113,6 +114,7 @@ export class DashboardComponent implements OnInit {
protected readonly USER_PYTHON_VENV = USER_PYTHON_VENV;
protected readonly USER_QUOTA = USER_QUOTA;
protected readonly USER_DISCUSSION = USER_DISCUSSION;
+ protected readonly USER_FEEDBACK = USER_FEEDBACK;
protected readonly ADMIN_USER = ADMIN_USER;
protected readonly ADMIN_GMAIL = ADMIN_GMAIL;
protected readonly ADMIN_EXECUTION = ADMIN_EXECUTION;
diff --git a/frontend/src/app/dashboard/component/user/feedback/feedback.component.html b/frontend/src/app/dashboard/component/user/feedback/feedback.component.html
new file mode 100644
index 0000000000..72da7bb214
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/feedback/feedback.component.html
@@ -0,0 +1,73 @@
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one
+ ~ or more contributor license agreements. See the NOTICE file
+ ~ distributed with this work for additional information
+ ~ regarding copyright ownership. The ASF licenses this file
+ ~ to you under the Apache License, Version 2.0 (the
+ ~ "License"); you may not use this file except in compliance
+ ~ with the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing,
+ ~ software distributed under the License is distributed on an
+ ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ ~ KIND, either express or implied. See the License for the
+ ~ specific language governing permissions and limitations
+ ~ under the License.
+ -->
+
+<div class="feedback-container">
+ <!-- Submit box: only when the user is viewing their own feedback -->
+ <nz-card
+ *ngIf="!isAdminView"
+ class="feedback-submit-card"
+ nzTitle="Send us your feedback">
+ <p class="feedback-hint">
+ Tell us what you like, what is broken, or what you would like to see next. Your feedback goes straight to the
+ Texera admins.
+ </p>
+ <textarea
+ nz-input
+ [(ngModel)]="newFeedback"
+ [disabled]="submitting"
+ placeholder="Type your feedback here..."
+ rows="4"
+ class="feedback-textarea"></textarea>
+ <button
+ nz-button
+ nzType="primary"
+ class="feedback-submit-button"
+ [nzLoading]="submitting"
+ [disabled]="newFeedback.trim().length === 0"
+ (click)="submitFeedback()">
+ <span
+ nz-icon
+ nzType="send"></span>
+ Submit Feedback
+ </button>
+ </nz-card>
+
+ <!-- Previous feedback table -->
+ <nz-card [nzTitle]="isAdminView ? 'User feedback' : 'Your previous feedback'">
+ <nz-table
+ #feedbackTable
+ [nzData]="[...feedbackList]"
+ [nzShowPagination]="feedbackList.length > 10"
+ [nzPageSize]="10"
+ nzSize="small">
+ <thead>
+ <tr>
+ <th nzWidth="220px">Submitted</th>
+ <th>Feedback</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr *ngFor="let feedback of feedbackTable.data">
+ <td>{{ feedback.creationTime | date: "MM/dd/y, h:mm a" }}</td>
+ <td class="feedback-message">{{ feedback.message }}</td>
+ </tr>
+ </tbody>
+ </nz-table>
+ </nz-card>
+</div>
diff --git a/frontend/src/app/dashboard/component/user/feedback/feedback.component.scss b/frontend/src/app/dashboard/component/user/feedback/feedback.component.scss
new file mode 100644
index 0000000000..95a8fc6981
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/feedback/feedback.component.scss
@@ -0,0 +1,47 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.feedback-container {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.feedback-hint {
+ color: rgba(0, 0, 0, 0.55);
+ margin-bottom: 12px;
+}
+
+.feedback-textarea {
+ margin-bottom: 16px;
+}
+
+.feedback-submit-button {
+ span[nz-icon] {
+ margin-right: 4px;
+ }
+}
+
+.feedback-message {
+ white-space: pre-wrap;
+ word-break: break-word;
+}
diff --git a/frontend/src/app/dashboard/component/user/feedback/feedback.component.spec.ts b/frontend/src/app/dashboard/component/user/feedback/feedback.component.spec.ts
new file mode 100644
index 0000000000..915ad9dcba
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/feedback/feedback.component.spec.ts
@@ -0,0 +1,123 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { of } from "rxjs";
+import { NZ_MODAL_DATA } from "ng-zorro-antd/modal";
+import { NzMessageService } from "ng-zorro-antd/message";
+
+import { FeedbackComponent } from "./feedback.component";
+import { FeedbackService } from "../../../service/user/feedback/feedback.service";
+import { commonTestProviders } from "../../../../common/testing/test-utils";
+import { Feedback } from "../../../type/feedback.interface";
+
+function makeFeedbackServiceSpy() {
+ return {
+ getMyFeedback: vi.fn().mockReturnValue(of([] as Feedback[])),
+ getUserFeedback: vi.fn().mockReturnValue(of([] as Feedback[])),
+ submitFeedback: vi.fn().mockReturnValue(of(undefined)),
+ getFeedbackCounts: vi.fn().mockReturnValue(of([])),
+ };
+}
+
+function makeMessageSpy() {
+ return { success: vi.fn(), error: vi.fn(), warning: vi.fn() };
+}
+
+describe("FeedbackComponent", () => {
+ describe("own-feedback (page) mode", () => {
+ let component: FeedbackComponent;
+ let fixture: ComponentFixture<FeedbackComponent>;
+ let feedbackSpy: ReturnType<typeof makeFeedbackServiceSpy>;
+ let messageSpy: ReturnType<typeof makeMessageSpy>;
+
+ beforeEach(async () => {
+ feedbackSpy = makeFeedbackServiceSpy();
+ messageSpy = makeMessageSpy();
+ await TestBed.configureTestingModule({
+ imports: [FeedbackComponent, HttpClientTestingModule],
+ providers: [
+ { provide: FeedbackService, useValue: feedbackSpy },
+ { provide: NzMessageService, useValue: messageSpy },
+ ...commonTestProviders,
+ ],
+ }).compileComponents();
+ fixture = TestBed.createComponent(FeedbackComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("creates and is not in admin view", () => {
+ expect(component).toBeTruthy();
+ expect(component.isAdminView).toBe(false);
+ });
+
+ it("loads the current user's own feedback on init", () => {
+ expect(feedbackSpy.getMyFeedback).toHaveBeenCalled();
+ expect(feedbackSpy.getUserFeedback).not.toHaveBeenCalled();
+ });
+
+ it("does not submit empty or whitespace-only feedback", () => {
+ component.newFeedback = " ";
+ component.submitFeedback();
+ expect(feedbackSpy.submitFeedback).not.toHaveBeenCalled();
+ expect(messageSpy.warning).toHaveBeenCalled();
+ });
+
+ it("submits trimmed feedback, clears the box, and reloads on success", () => {
+ feedbackSpy.getMyFeedback.mockClear();
+ component.newFeedback = " great tool ";
+ component.submitFeedback();
+ expect(feedbackSpy.submitFeedback).toHaveBeenCalledWith("great tool");
+ expect(component.newFeedback).toBe("");
+ expect(messageSpy.success).toHaveBeenCalled();
+ expect(feedbackSpy.getMyFeedback).toHaveBeenCalled();
+ });
+ });
+
+ describe("admin (modal) mode", () => {
+ let component: FeedbackComponent;
+ let fixture: ComponentFixture<FeedbackComponent>;
+ let feedbackSpy: ReturnType<typeof makeFeedbackServiceSpy>;
+
+ beforeEach(async () => {
+ feedbackSpy = makeFeedbackServiceSpy();
+ await TestBed.configureTestingModule({
+ imports: [FeedbackComponent, HttpClientTestingModule],
+ providers: [
+ { provide: FeedbackService, useValue: feedbackSpy },
+ { provide: NzMessageService, useValue: makeMessageSpy() },
+ { provide: NZ_MODAL_DATA, useValue: { uid: 42 } },
+ ...commonTestProviders,
+ ],
+ }).compileComponents();
+ fixture = TestBed.createComponent(FeedbackComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("is in admin view and loads the target user's feedback", () => {
+ expect(component.isAdminView).toBe(true);
+ expect(component.adminUid).toBe(42);
+ expect(feedbackSpy.getUserFeedback).toHaveBeenCalledWith(42);
+ expect(feedbackSpy.getMyFeedback).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/frontend/src/app/dashboard/component/user/feedback/feedback.component.ts b/frontend/src/app/dashboard/component/user/feedback/feedback.component.ts
new file mode 100644
index 0000000000..e4e114a939
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/feedback/feedback.component.ts
@@ -0,0 +1,129 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, inject, OnInit } from "@angular/core";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { NgFor, NgIf, DatePipe } from "@angular/common";
+import { FormsModule } from "@angular/forms";
+import { NzCardComponent } from "ng-zorro-antd/card";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzWaveDirective } from "ng-zorro-antd/core/wave";
+import { NzInputDirective } from "ng-zorro-antd/input";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import {
+ NzTableComponent,
+ NzTheadComponent,
+ NzTrDirective,
+ NzTableCellDirective,
+ NzThMeasureDirective,
+ NzTbodyComponent,
+} from "ng-zorro-antd/table";
+import { NzMessageService } from "ng-zorro-antd/message";
+import { NZ_MODAL_DATA } from "ng-zorro-antd/modal";
+import { FeedbackService } from "../../../service/user/feedback/feedback.service";
+import { Feedback } from "../../../type/feedback.interface";
+
+/**
+ * Feedback view. Used in two modes:
+ * - As a routed page (no modal data): the logged-in user submits feedback and
+ * sees a table of their own previous feedback.
+ * - As modal content with `{ uid }` injected via NZ_MODAL_DATA: an admin views
+ * a specific user's feedback read-only (no submit box).
+ */
+@UntilDestroy()
+@Component({
+ selector: "texera-feedback",
+ templateUrl: "./feedback.component.html",
+ styleUrls: ["./feedback.component.scss"],
+ imports: [
+ NgFor,
+ NgIf,
+ DatePipe,
+ FormsModule,
+ NzCardComponent,
+ NzButtonComponent,
+ NzWaveDirective,
+ NzInputDirective,
+ NzIconDirective,
+ NzTableComponent,
+ NzTheadComponent,
+ NzTrDirective,
+ NzTableCellDirective,
+ NzThMeasureDirective,
+ NzTbodyComponent,
+ ],
+})
+export class FeedbackComponent implements OnInit {
+ // When set, the component is showing another user's feedback (admin modal view).
+ readonly adminUid: number | undefined = inject(NZ_MODAL_DATA, { optional: true })?.uid;
+ newFeedback: string = "";
+ submitting: boolean = false;
+ feedbackList: ReadonlyArray<Feedback> = [];
+
+ constructor(
+ private feedbackService: FeedbackService,
+ private messageService: NzMessageService
+ ) {}
+
+ get isAdminView(): boolean {
+ return this.adminUid !== undefined;
+ }
+
+ ngOnInit(): void {
+ this.loadFeedback();
+ }
+
+ loadFeedback(): void {
+ const request$ = this.isAdminView
+ ? this.feedbackService.getUserFeedback(this.adminUid as number)
+ : this.feedbackService.getMyFeedback();
+ request$.pipe(untilDestroyed(this)).subscribe({
+ next: feedbackList => (this.feedbackList = feedbackList),
+ error: (err: unknown) => this.messageService.error(this.extractError(err)),
+ });
+ }
+
+ submitFeedback(): void {
+ const message = this.newFeedback.trim();
+ if (message.length === 0) {
+ this.messageService.warning("Feedback cannot be empty.");
+ return;
+ }
+ this.submitting = true;
+ this.feedbackService
+ .submitFeedback(message)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: () => {
+ this.submitting = false;
+ this.newFeedback = "";
+ this.messageService.success("Thank you for your feedback!");
+ this.loadFeedback();
+ },
+ error: (err: unknown) => {
+ this.submitting = false;
+ this.messageService.error(this.extractError(err));
+ },
+ });
+ }
+
+ private extractError(err: unknown): string {
+ return (err as any)?.error?.message || (err as Error)?.message || "An unexpected error occurred.";
+ }
+}
diff --git a/frontend/src/app/dashboard/service/user/feedback/feedback.service.spec.ts b/frontend/src/app/dashboard/service/user/feedback/feedback.service.spec.ts
new file mode 100644
index 0000000000..37a4217b8c
--- /dev/null
+++ b/frontend/src/app/dashboard/service/user/feedback/feedback.service.spec.ts
@@ -0,0 +1,84 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
+import { firstValueFrom } from "rxjs";
+
+import { FeedbackService } from "./feedback.service";
+import { AppSettings } from "../../../../common/app-setting";
+import { commonTestProviders } from "../../../../common/testing/test-utils";
+import { Feedback, FeedbackCount } from "../../../type/feedback.interface";
+
+const API = "api";
+
+describe("FeedbackService", () => {
+ let service: FeedbackService;
+ let http: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ providers: [FeedbackService, ...commonTestProviders],
+ });
+ service = TestBed.inject(FeedbackService);
+ http = TestBed.inject(HttpTestingController);
+ vi.spyOn(AppSettings, "getApiEndpoint").mockReturnValue(API);
+ });
+
+ afterEach(() => {
+ http.verify();
+ });
+
+ it("submitFeedback POSTs the message to /feedback", () => {
+ service.submitFeedback("hello world").subscribe();
+ const req = http.expectOne(`${API}/feedback`);
+ expect(req.request.method).toBe("POST");
+ expect(req.request.body).toEqual({ message: "hello world" });
+ req.flush(null);
+ });
+
+ it("getMyFeedback GETs /feedback and returns the list", async () => {
+ const expected: ReadonlyArray<Feedback> = [{ fid: 1, uid: 7, message: "m", creationTime: 123 }];
+ const pending = firstValueFrom(service.getMyFeedback());
+ const req = http.expectOne(`${API}/feedback`);
+ expect(req.request.method).toBe("GET");
+ req.flush(expected);
+ expect(await pending).toEqual(expected);
+ });
+
+ it("getFeedbackCounts GETs /feedback/counts", async () => {
+ const expected: ReadonlyArray<FeedbackCount> = [{ uid: 7, count: 3 }];
+ const pending = firstValueFrom(service.getFeedbackCounts());
+ const req = http.expectOne(`${API}/feedback/counts`);
+ expect(req.request.method).toBe("GET");
+ req.flush(expected);
+ expect(await pending).toEqual(expected);
+ });
+
+ it("getUserFeedback GETs /feedback/user with a user_id query param", async () => {
+ const expected: ReadonlyArray<Feedback> = [{ fid: 2, uid: 42, message: "x", creationTime: 9 }];
+ const pending = firstValueFrom(service.getUserFeedback(42));
+ const req = http.expectOne(r => r.url === `${API}/feedback/user`);
+ expect(req.request.method).toBe("GET");
+ expect(req.request.params.get("user_id")).toBe("42");
+ req.flush(expected);
+ expect(await pending).toEqual(expected);
+ });
+});
diff --git a/frontend/src/app/dashboard/service/user/feedback/feedback.service.ts b/frontend/src/app/dashboard/service/user/feedback/feedback.service.ts
new file mode 100644
index 0000000000..e6955425b5
--- /dev/null
+++ b/frontend/src/app/dashboard/service/user/feedback/feedback.service.ts
@@ -0,0 +1,56 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { HttpClient, HttpParams } from "@angular/common/http";
+import { Injectable } from "@angular/core";
+import { Observable } from "rxjs";
+import { AppSettings } from "../../../../common/app-setting";
+import { Feedback, FeedbackCount } from "../../../type/feedback.interface";
+
+export const FEEDBACK_BASE_URL = `${AppSettings.getApiEndpoint()}/feedback`;
+export const FEEDBACK_COUNTS_URL = `${FEEDBACK_BASE_URL}/counts`;
+export const FEEDBACK_USER_URL = `${FEEDBACK_BASE_URL}/user`;
+
+@Injectable({
+ providedIn: "root",
+})
+export class FeedbackService {
+ constructor(private http: HttpClient) {}
+
+ /** Submit a new feedback message for the current user. */
+ public submitFeedback(message: string): Observable<void> {
+ return this.http.post<void>(`${FEEDBACK_BASE_URL}`, { message });
+ }
+
+ /** List the current user's own feedback, newest first. */
+ public getMyFeedback(): Observable<ReadonlyArray<Feedback>> {
+ return this.http.get<ReadonlyArray<Feedback>>(`${FEEDBACK_BASE_URL}`);
+ }
+
+ /** Admin only: feedback counts per user (only users with >= 1 feedback). */
+ public getFeedbackCounts(): Observable<ReadonlyArray<FeedbackCount>> {
+ return this.http.get<ReadonlyArray<FeedbackCount>>(`${FEEDBACK_COUNTS_URL}`);
+ }
+
+ /** Admin only: list the feedback submitted by a specific user, newest first. */
+ public getUserFeedback(uid: number): Observable<ReadonlyArray<Feedback>> {
+ const params = new HttpParams().set("user_id", uid.toString());
+ return this.http.get<ReadonlyArray<Feedback>>(`${FEEDBACK_USER_URL}`, { params });
+ }
+}
diff --git a/frontend/src/app/dashboard/type/feedback.interface.ts b/frontend/src/app/dashboard/type/feedback.interface.ts
new file mode 100644
index 0000000000..d045fb0c5a
--- /dev/null
+++ b/frontend/src/app/dashboard/type/feedback.interface.ts
@@ -0,0 +1,34 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/** A single feedback message owned by a user. `creationTime` is epoch milliseconds. */
+export interface Feedback
+ extends Readonly<{
+ fid: number;
+ uid: number;
+ message: string;
+ creationTime: number;
+ }> {}
+
+/** Number of feedback messages submitted by a single user. */
+export interface FeedbackCount
+ extends Readonly<{
+ uid: number;
+ count: number;
+ }> {}
diff --git a/sql/changelog.xml b/sql/changelog.xml
index 39119f538b..e216caf3d0 100644
--- a/sql/changelog.xml
+++ b/sql/changelog.xml
@@ -33,6 +33,11 @@
<sqlFile path="sql/updates/24.sql"/>
</changeSet>
+ <!-- Add feedback table -->
+ <changeSet id="25" author="arisheh">
+ <sqlFile path="sql/updates/25.sql"/>
+ </changeSet>
+
<!-- example changeSet
<changeSet id="1" author="author">
<sqlFile path="sql/updates/1.sql"/>
diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql
index 26b009e420..9728829851 100644
--- a/sql/texera_ddl.sql
+++ b/sql/texera_ddl.sql
@@ -123,6 +123,16 @@ CREATE TABLE IF NOT EXISTS user_config
FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE
);
+-- feedback
+CREATE TABLE IF NOT EXISTS feedback
+(
+ fid SERIAL PRIMARY KEY,
+ uid INT NOT NULL,
+ message TEXT NOT NULL,
+ creation_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE
+ );
+
-- workflow
CREATE TABLE IF NOT EXISTS workflow
(
diff --git a/sql/updates/25.sql b/sql/updates/25.sql
new file mode 100644
index 0000000000..731ae3f468
--- /dev/null
+++ b/sql/updates/25.sql
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+\c texera_db
+
+SET search_path TO texera_db;
+
+BEGIN;
+
+-- Adds the feedback table, used to persist free-text feedback messages
+-- submitted by users from the dashboard. Each row is one feedback message
+-- owned by a user; deleting the user cascades to their feedback.
+CREATE TABLE IF NOT EXISTS feedback
+(
+ fid SERIAL PRIMARY KEY,
+ uid INT NOT NULL,
+ message TEXT NOT NULL,
+ creation_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE
+);
+
+COMMIT;