Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.opendatamask.controller

import com.opendatamask.dto.PostJobActionRequest
import com.opendatamask.model.PostJobAction
import com.opendatamask.service.PostJobActionService
import org.springframework.http.HttpStatus
Expand All @@ -18,12 +19,25 @@ class PostJobActionController(
@PostMapping
fun createAction(
@PathVariable workspaceId: Long,
@RequestBody action: PostJobAction
@RequestBody request: PostJobActionRequest
): ResponseEntity<PostJobAction> {
val toSave = action.copy(workspaceId = workspaceId)
val toSave = PostJobAction(
workspaceId = workspaceId,
actionType = request.actionType,
config = request.config,
enabled = request.enabled
)
return ResponseEntity.status(HttpStatus.CREATED).body(service.createAction(toSave))
}

@PutMapping("/{actionId}")
fun updateAction(
@PathVariable workspaceId: Long,
@PathVariable actionId: Long,
@RequestBody request: PostJobActionRequest
): ResponseEntity<PostJobAction> =
ResponseEntity.ok(service.updateAction(workspaceId, actionId, request))

@DeleteMapping("/{actionId}")
fun deleteAction(
@PathVariable workspaceId: Long,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.opendatamask.dto

import com.opendatamask.model.ActionType

data class PostJobActionRequest(
val actionType: ActionType,
val config: String = "{}",
val enabled: Boolean = true
)
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,16 @@ class PostJobActionService(

fun createAction(action: PostJobAction): PostJobAction = repository.save(action)
fun listActions(workspaceId: Long): List<PostJobAction> = repository.findByWorkspaceId(workspaceId)
fun updateAction(workspaceId: Long, id: Long, request: com.opendatamask.dto.PostJobActionRequest): PostJobAction {
val existing = repository.findById(id)
.orElseThrow { NoSuchElementException("PostJobAction not found: $id") }
if (existing.workspaceId != workspaceId) {
throw NoSuchElementException("PostJobAction $id does not belong to workspace $workspaceId")
}
existing.actionType = request.actionType
existing.config = request.config
existing.enabled = request.enabled
return repository.save(existing)
}
fun deleteAction(id: Long) = repository.deleteById(id)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.opendatamask.controller

import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.opendatamask.dto.PostJobActionRequest
import com.opendatamask.model.ActionType
import com.opendatamask.model.PostJobAction
import com.opendatamask.security.JwtAuthenticationFilter
Expand Down Expand Up @@ -51,12 +52,13 @@ class PostJobActionControllerTest {

@Test
fun `POST create action returns 201`() {
val action = PostJobAction(workspaceId = 1L, actionType = ActionType.WEBHOOK, config = """{"url":"http://example.com"}""")
whenever(service.createAction(any<PostJobAction>())).thenReturn(action.copy(id = 1L))
val request = PostJobActionRequest(actionType = ActionType.WEBHOOK, config = """{"url":"http://example.com"}""")
val saved = PostJobAction(id = 1L, workspaceId = 1L, actionType = ActionType.WEBHOOK, config = """{"url":"http://example.com"}""")
whenever(service.createAction(any<PostJobAction>())).thenReturn(saved)
mockMvc.perform(
post("/api/workspaces/1/actions")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(action))
.content(mapper.writeValueAsString(request))
).andExpect(status().isCreated)
}

Expand All @@ -66,4 +68,30 @@ class PostJobActionControllerTest {
.andExpect(status().isNoContent)
verify(service).deleteAction(42L)
}

@Test
fun `PUT update action returns 200 with correct response body`() {
val request = PostJobActionRequest(
actionType = ActionType.WEBHOOK,
config = """{"url":"http://example.com"}"""
)
val returned = PostJobAction(
id = 42L,
workspaceId = 1L,
actionType = ActionType.WEBHOOK,
config = """{"url":"http://example.com"}"""
)
whenever(service.updateAction(eq(1L), eq(42L), any<PostJobActionRequest>())).thenReturn(returned)
mockMvc.perform(
put("/api/workspaces/1/actions/42")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.workspaceId").value(1))
.andExpect(jsonPath("$.actionType").value("WEBHOOK"))
.andExpect(jsonPath("$.config").value("""{"url":"http://example.com"}"""))
verify(service).updateAction(eq(1L), eq(42L), any<PostJobActionRequest>())
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.opendatamask.service

import com.opendatamask.dto.PostJobActionRequest
import com.opendatamask.model.ActionType
import com.opendatamask.model.Job
import com.opendatamask.model.JobStatus
import com.opendatamask.model.PostJobAction
import com.opendatamask.repository.PostJobActionRepository
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.assertThrows
import org.mockito.kotlin.*
import java.time.LocalDateTime

Expand Down Expand Up @@ -68,4 +70,39 @@ class PostJobActionServiceTest {
service.deleteAction(42L)
verify(repository).deleteById(42L)
}

@Test
fun `updateAction updates fields and saves`() {
val existing = PostJobAction(
id = 1L, workspaceId = 1L,
actionType = ActionType.EMAIL,
config = """{"to":"old@example.com"}"""
)
val request = PostJobActionRequest(
actionType = ActionType.WEBHOOK,
config = """{"url":"http://new.example.com"}""",
enabled = false
)
whenever(repository.findById(1L)).thenReturn(java.util.Optional.of(existing))
whenever(repository.save(any<PostJobAction>())).thenReturn(existing)
service.updateAction(1L, 1L, request)
verify(repository).save(existing)
assertEquals(ActionType.WEBHOOK, existing.actionType)
assertEquals("""{"url":"http://new.example.com"}""", existing.config)
assertEquals(false, existing.enabled)
}

@Test
fun `updateAction throws when action belongs to different workspace`() {
val existing = PostJobAction(
id = 1L, workspaceId = 99L,
actionType = ActionType.EMAIL,
config = """{"to":"other@example.com"}"""
)
whenever(repository.findById(1L)).thenReturn(java.util.Optional.of(existing))
assertThrows<NoSuchElementException> {
service.updateAction(1L, 1L, PostJobActionRequest(actionType = ActionType.EMAIL, config = "{}"))
}
verify(repository, never()).save(any())
}
}
34 changes: 34 additions & 0 deletions frontend/src/api/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import apiClient from './client'
import type { PostJobAction, PostJobActionRequest } from '@/types'

export async function listActions(workspaceId: number): Promise<PostJobAction[]> {
const { data } = await apiClient.get<PostJobAction[]>(`/workspaces/${workspaceId}/actions`)
return data
}

export async function createAction(
workspaceId: number,
payload: PostJobActionRequest
): Promise<PostJobAction> {
const { data } = await apiClient.post<PostJobAction>(
`/workspaces/${workspaceId}/actions`,
payload
)
return data
}

export async function updateAction(
workspaceId: number,
actionId: number,
payload: PostJobActionRequest
): Promise<PostJobAction> {
const { data } = await apiClient.put<PostJobAction>(
`/workspaces/${workspaceId}/actions/${actionId}`,
payload
)
return data
}

export async function deleteAction(workspaceId: number, actionId: number): Promise<void> {
await apiClient.delete(`/workspaces/${workspaceId}/actions/${actionId}`)
}
128 changes: 128 additions & 0 deletions frontend/src/components/AppModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'

const props = defineProps<{
title?: string
size?: 'sm' | 'md' | 'lg'
}>()

const emit = defineEmits<{
close: []
}>()

function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}

onMounted(() => {
document.addEventListener('keydown', handleKeydown)
document.body.style.overflow = 'hidden'
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
document.body.style.overflow = ''
})
</script>

<template>
<Teleport to="body">
<div class="modal-backdrop" @click.self="emit('close')">
<div
class="modal-box"
:class="[`modal-${props.size ?? 'md'}`]"
role="dialog"
aria-modal="true"
>
<!-- Header -->
<div v-if="title || $slots.header" class="modal-header">
<slot name="header">
<h3 class="modal-title">{{ title }}</h3>
</slot>
<button class="modal-close" aria-label="Close" @click="emit('close')">✕</button>
</div>

<!-- Body -->
<div class="modal-body">
<slot />
</div>

<!-- Footer -->
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer" />
</div>
</div>
</div>
</Teleport>
</template>

<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }

.modal-box {
background: #fff;
border-radius: 0.5rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
max-height: 90vh;
width: 100%;
animation: slideUp 0.15s ease;
}
@keyframes slideUp {
from { transform: translateY(16px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}

.modal-sm { max-width: 400px; }
.modal-md { max-width: 560px; }
.modal-lg { max-width: 800px; }

.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.modal-title { font-size: 1.125rem; font-weight: 600; color: #111827; }
.modal-close {
background: none;
border: none;
font-size: 1rem;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
border-radius: 0.25rem;
transition: background 0.15s;
}
.modal-close:hover { background: #f3f4f6; color: #111827; }

.modal-body {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}

.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
flex-shrink: 0;
}
</style>
7 changes: 7 additions & 0 deletions frontend/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import WorkspaceDetailView from '@/views/WorkspaceDetailView.vue'
import ConnectionsView from '@/views/ConnectionsView.vue'
import TablesView from '@/views/TablesView.vue'
import JobsView from '@/views/JobsView.vue'
import ActionsView from '@/views/ActionsView.vue'

const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
Expand Down Expand Up @@ -60,6 +61,12 @@ const router = createRouter({
name: 'jobs',
component: JobsView,
meta: { requiresAuth: true }
},
{
path: '/workspaces/:id/actions',
name: 'actions',
component: ActionsView,
meta: { requiresAuth: true }
}
]
})
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,29 @@ export interface JobLog {
timestamp: string
}

// ── Post-Job Action ───────────────────────────────────────────────────────

export enum ActionType {
WEBHOOK = 'WEBHOOK',
EMAIL = 'EMAIL',
SCRIPT = 'SCRIPT'
}

export interface PostJobAction {
id: number
workspaceId: number
actionType: ActionType
config: string
enabled: boolean
createdAt: string
}

export interface PostJobActionRequest {
actionType: ActionType
config: string
enabled?: boolean
}

// ── Pagination ────────────────────────────────────────────────────────────

export interface Page<T> {
Expand Down
Loading
Loading