created new dialogs for device
This commit is contained in:
@@ -6,6 +6,8 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import EditDeviceDialog from './EditDeviceDialog.vue'
|
import EditDeviceDialog from './EditDeviceDialog.vue'
|
||||||
import DeleteDeviceDialog from './DeleteDeviceDialog.vue'
|
import DeleteDeviceDialog from './DeleteDeviceDialog.vue'
|
||||||
|
import DeviceCertificateDialog from './DeviceCertificateDialog.vue'
|
||||||
|
import DeviceTasksDialog from './DeviceTasksDialog.vue'
|
||||||
import { Ellipsis } from 'lucide-vue-next'
|
import { Ellipsis } from 'lucide-vue-next'
|
||||||
import type { Device } from '@/lib/interfaces'
|
import type { Device } from '@/lib/interfaces'
|
||||||
// import { api } from '@/lib/api'
|
// import { api } from '@/lib/api'
|
||||||
@@ -14,6 +16,8 @@ const props = defineProps<{ row: Device }>() // ← accept full row
|
|||||||
|
|
||||||
const isEditOpen = ref(false)
|
const isEditOpen = ref(false)
|
||||||
const isDeleteOpen = ref(false)
|
const isDeleteOpen = ref(false)
|
||||||
|
const isTasksOpen = ref(false)
|
||||||
|
const itCertsOpen = ref(false)
|
||||||
|
|
||||||
function onDeleteConfirmed() {
|
function onDeleteConfirmed() {
|
||||||
// await api.delete(`/devices/${encodeURIComponent(props.row.guid)}`)
|
// await api.delete(`/devices/${encodeURIComponent(props.row.guid)}`)
|
||||||
@@ -22,6 +26,14 @@ function onDeleteConfirmed() {
|
|||||||
function onEditConfirm() {
|
function onEditConfirm() {
|
||||||
isEditOpen.value = false
|
isEditOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onTaskConfirm() {
|
||||||
|
isTasksOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCertsConfirm() {
|
||||||
|
itCertsOpen.value = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -33,10 +45,14 @@ function onEditConfirm() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" class="w-[160px]">
|
<DropdownMenuContent align="end" class="w-[160px]">
|
||||||
<DropdownMenuItem @click.prevent="isEditOpen = true">Rename</DropdownMenuItem>
|
<DropdownMenuItem @click.prevent="isEditOpen = true">Rename</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click.prevent="isTasksOpen = true">Tasks</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click.prevent="itCertsOpen = true">Certificates</DropdownMenuItem>
|
||||||
<DropdownMenuItem @click.prevent="isDeleteOpen = true">Delete</DropdownMenuItem>
|
<DropdownMenuItem @click.prevent="isDeleteOpen = true">Delete</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<EditDeviceDialog v-model:modelValue="isEditOpen" :device="props.row" @confirm="onEditConfirm" />
|
<EditDeviceDialog v-model:modelValue="isEditOpen" :device="props.row" @confirm="onEditConfirm" />
|
||||||
<DeleteDeviceDialog v-model:modelValue="isDeleteOpen" :device="props.row" @confirm="onDeleteConfirmed" />
|
<DeleteDeviceDialog v-model:modelValue="isDeleteOpen" :device="props.row" @confirm="onDeleteConfirmed" />
|
||||||
|
<DeviceCertificateDialog v-model:modelValue="itCertsOpen" :device="props.row" @confirm="onCertsConfirm" />
|
||||||
|
<DeviceTasksDialog v-model:modelValue="isTasksOpen" :device="props.row" @confirm="onTaskConfirm" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useFilter } from 'reka-ui'
|
import { useFilter } from 'reka-ui'
|
||||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
Combobox, ComboboxAnchor, ComboboxEmpty, ComboboxGroup,
|
Combobox, ComboboxAnchor, ComboboxEmpty, ComboboxGroup,
|
||||||
ComboboxInput, ComboboxItem, ComboboxList
|
ComboboxInput, ComboboxItem, ComboboxList
|
||||||
@@ -11,63 +11,52 @@ import {
|
|||||||
import type { Users } from '@/lib/interfaces'
|
import type { Users } from '@/lib/interfaces'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
// ——— v-model plumbing ———
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: string[] // selected user IDs (strings)
|
modelValue: string[] // selected user IDs
|
||||||
}>()
|
allUsers?: Users[] // OPTIONAL: full users list provided by parent
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', v: string[]): void
|
|
||||||
}>()
|
}>()
|
||||||
|
const emit = defineEmits<{ (e:'update:modelValue', v:string[]):void }>()
|
||||||
|
|
||||||
// computed proxy to v-model
|
// computed proxy
|
||||||
const selected = computed<string[]>({
|
const selected = computed<string[]>({
|
||||||
get: () => props.modelValue ?? [],
|
get: () => props.modelValue ?? [],
|
||||||
set: (v) => emit('update:modelValue', v),
|
set: (v) => emit('update:modelValue', v),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ——— local state ———
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
|
|
||||||
const allUsers = ref<Users[]>([])
|
/** SOURCE of truth for users used by the combobox */
|
||||||
|
const internalUsers = ref<Users[]>([]) // used only when parent didn't pass allUsers
|
||||||
|
const usingExternal = computed(() => Array.isArray(props.allUsers))
|
||||||
|
const effectiveUsers = computed<Users[]>(() => usingExternal.value ? (props.allUsers as Users[]) : internalUsers.value)
|
||||||
|
|
||||||
|
// self-fetch only when parent didn't provide allUsers
|
||||||
let ctrl: AbortController | null = null
|
let ctrl: AbortController | null = null
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
error.value = null
|
if (usingExternal.value) return
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
ctrl?.abort()
|
ctrl?.abort()
|
||||||
ctrl = new AbortController()
|
ctrl = new AbortController()
|
||||||
const { data } = await api.get<Users[]>('/users', { signal: ctrl.signal })
|
const { data } = await api.get<Users[]>('/users', { signal: ctrl.signal })
|
||||||
if (!Array.isArray(data)) throw new Error('Unexpected response')
|
if (Array.isArray(data)) {
|
||||||
allUsers.value = data.filter(
|
internalUsers.value = data.filter((u:any) => typeof u?.id === 'number' && typeof u?.username === 'string')
|
||||||
(u: any) => typeof u?.id === 'number' && typeof u?.username === 'string'
|
}
|
||||||
)
|
} catch { /* ignore */ }
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name === 'CanceledError' || e?.message === 'canceled') return
|
|
||||||
error.value = 'Failed to load users.'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadUsers)
|
onMounted(loadUsers)
|
||||||
onBeforeUnmount(() => ctrl?.abort())
|
onBeforeUnmount(() => ctrl?.abort())
|
||||||
|
|
||||||
type UserOption = { value: string; label: string }
|
type UserOption = { value: string; label: string }
|
||||||
const users = computed<UserOption[]>(() =>
|
const users = computed<UserOption[]>(() =>
|
||||||
allUsers.value.map(u => ({ value: String(u.id), label: u.username }))
|
effectiveUsers.value.map(u => ({ value: String(u.id), label: u.username }))
|
||||||
)
|
)
|
||||||
|
|
||||||
const { contains } = useFilter({ sensitivity: 'base' })
|
const { contains } = useFilter({ sensitivity: 'base' })
|
||||||
const filteredUsers = computed(() => {
|
const filteredUsers = computed(() => {
|
||||||
const selectedSet = new Set(selected.value)
|
const selectedSet = new Set(selected.value)
|
||||||
const options = users.value.filter(o => !selectedSet.has(o.value))
|
const options = users.value.filter(o => !selectedSet.has(o.value))
|
||||||
return searchTerm.value
|
return searchTerm.value ? options.filter(o => contains(o.label, searchTerm.value)) : options
|
||||||
? options.filter(o => contains(o.label, searchTerm.value))
|
|
||||||
: options
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function userLabelById(id: string) {
|
function userLabelById(id: string) {
|
||||||
@@ -77,21 +66,20 @@ function userLabelById(id: string) {
|
|||||||
function onSelect(ev: CustomEvent) {
|
function onSelect(ev: CustomEvent) {
|
||||||
const val = String((ev as any).detail?.value ?? '')
|
const val = String((ev as any).detail?.value ?? '')
|
||||||
if (!val) return
|
if (!val) return
|
||||||
if (!selected.value.includes(val)) {
|
if (!selected.value.includes(val)) selected.value = [...selected.value, val]
|
||||||
// assign a new array so v-model updates properly
|
|
||||||
selected.value = [...selected.value, val]
|
|
||||||
}
|
|
||||||
searchTerm.value = ''
|
searchTerm.value = ''
|
||||||
if (filteredUsers.value.length === 0) {
|
if (filteredUsers.value.length === 0) open.value = false
|
||||||
open.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When parent switches between external/internal data at runtime, refetch if needed.
|
||||||
|
watch(() => props.allUsers, () => { if (!usingExternal.value) loadUsers() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Combobox v-model="selected" v-model:open="open" :ignore-filter="true">
|
<Combobox v-model="selected" v-model:open="open" :ignore-filter="true">
|
||||||
<ComboboxAnchor as-child>
|
<ComboboxAnchor as-child>
|
||||||
<TagsInput v-model="selected" class="px-2 gap-2 w-90">
|
<div class="relative w-full">
|
||||||
|
<TagsInput v-model="selected" class="w-full px-2 gap-2">
|
||||||
<div class="flex gap-2 flex-wrap items-center">
|
<div class="flex gap-2 flex-wrap items-center">
|
||||||
<TagsInputItem v-for="id in selected" :key="id" :value="id">
|
<TagsInputItem v-for="id in selected" :key="id" :value="id">
|
||||||
<span class="px-1">{{ userLabelById(id) }}</span>
|
<span class="px-1">{{ userLabelById(id) }}</span>
|
||||||
@@ -99,26 +87,22 @@ function onSelect(ev: CustomEvent) {
|
|||||||
</TagsInputItem>
|
</TagsInputItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ComboboxInput v-model="searchTerm" as-child>
|
<ComboboxInput v-model="searchTerm" as-child>
|
||||||
<TagsInputInput
|
<TagsInputInput
|
||||||
placeholder="User..."
|
placeholder="User..."
|
||||||
class="min-w-[300px] w-full p-0 border-none focus-visible:ring-0 h-auto"
|
class="min-w-[200px] w-full p-0 border-none focus-visible:ring-0 h-auto"
|
||||||
@keydown.enter.prevent
|
@keydown.enter.prevent
|
||||||
/>
|
/>
|
||||||
</ComboboxInput>
|
</ComboboxInput>
|
||||||
</TagsInput>
|
</TagsInput>
|
||||||
|
|
||||||
<!-- match input width -->
|
<ComboboxList class="w-[--reka-popper-anchor-width] w-full min-w-[350px]">
|
||||||
<ComboboxList
|
|
||||||
class="w-[--reka-popper-anchor-width] min-w-[350px]"
|
|
||||||
>
|
|
||||||
<ComboboxEmpty>
|
<ComboboxEmpty>
|
||||||
<span v-if="loading">Loading…</span>
|
<span v-if="!usingExternal && !effectiveUsers.length">Loading…</span>
|
||||||
<span v-else-if="error">{{ error }}</span>
|
<span v-else-if="!users.length">No users found.</span>
|
||||||
<span v-else>No users found.</span>
|
|
||||||
</ComboboxEmpty>
|
</ComboboxEmpty>
|
||||||
|
|
||||||
<ComboboxGroup v-if="!loading && !error && filteredUsers.length">
|
<ComboboxGroup v-if="filteredUsers.length">
|
||||||
<ComboboxItem
|
<ComboboxItem
|
||||||
v-for="usr in filteredUsers"
|
v-for="usr in filteredUsers"
|
||||||
:key="usr.value"
|
:key="usr.value"
|
||||||
@@ -129,6 +113,7 @@ function onSelect(ev: CustomEvent) {
|
|||||||
</ComboboxItem>
|
</ComboboxItem>
|
||||||
</ComboboxGroup>
|
</ComboboxGroup>
|
||||||
</ComboboxList>
|
</ComboboxList>
|
||||||
|
</div>
|
||||||
</ComboboxAnchor>
|
</ComboboxAnchor>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import type { Device } from '@/lib/interfaces';
|
||||||
|
import type { PropType } from 'vue';
|
||||||
|
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean as PropType<boolean>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
device: { type: Object as PropType<Device>, required: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', v: boolean): void
|
||||||
|
(e: 'confirm'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
emit('confirm')
|
||||||
|
// close the dialog
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Dialog :open="props.modelValue" @update:open="(v: boolean) => emit('update:modelValue', v)">
|
||||||
|
<DialogContent class="sm:min-w-[800px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>List of certificates</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
List of certificates for device {{ props.device?.guid }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button @click="onSave">Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -32,7 +32,7 @@ const usrIDs = selectedUserIds.value
|
|||||||
Dashboard
|
Dashboard
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="flex-1 space-y-6">
|
<CardContent class="flex-1 grid gap-4 py-4">
|
||||||
<div class="grid gap-5">
|
<div class="grid gap-5">
|
||||||
<div class="grid space-y-2 grid-cols-4 items-center">
|
<div class="grid space-y-2 grid-cols-4 items-center">
|
||||||
<Label for="users">Allowed users</Label>
|
<Label for="users">Allowed users</Label>
|
||||||
|
|||||||
48
management-ui/src/customcompometns/DeviceTasksDialog.vue
Normal file
48
management-ui/src/customcompometns/DeviceTasksDialog.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import type { Device } from '@/lib/interfaces';
|
||||||
|
import type { PropType } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean as PropType<boolean>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
device: { type: Object as PropType<Device>, required: false },
|
||||||
|
})
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', v: boolean): void
|
||||||
|
(e: 'confirm'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
emit('confirm')
|
||||||
|
// close the dialog
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Dialog :open="props.modelValue" @update:open="(v: boolean) => emit('update:modelValue', v)">
|
||||||
|
<DialogContent class="sm:min-w-[800px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Tasks</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
List of tasks for device {{ props.device?.guid }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button @click="onSave">Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -13,7 +13,7 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { defineProps, defineEmits, ref, watch } from 'vue'
|
import { defineProps, defineEmits, ref, watch } from 'vue'
|
||||||
import type { PropType } from 'vue'
|
import type { PropType } from 'vue'
|
||||||
import AssignDevice from './AssignDevice.vue'
|
import AssignDevice from './AssignDevice.vue'
|
||||||
import type { Device } from '@/lib/interfaces'
|
import type { Device, Users } from '@/lib/interfaces'
|
||||||
|
|
||||||
// 1) runtime props so Vue + TS agree
|
// 1) runtime props so Vue + TS agree
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -22,6 +22,8 @@ const props = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
device: { type: Object as PropType<Device>, required: false },
|
device: { type: Object as PropType<Device>, required: false },
|
||||||
|
allUsers: { type: Array as PropType<Users[]>, required: false },
|
||||||
|
initialUserIds: { type: Array as PropType<string[]>, required: false }, // <- if you have IDs already
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2) two emits: v-model and confirm
|
// 2) two emits: v-model and confirm
|
||||||
@@ -31,11 +33,37 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
|
const selectedUserIds = ref<string[]>([])
|
||||||
|
|
||||||
|
// helper: map usernames → ids when we only have a string of usernames
|
||||||
|
const usernameToId = (uname: string): string | null => {
|
||||||
|
const id = props.allUsers?.find(u => u.username === uname)?.id
|
||||||
|
return typeof id === 'number' ? String(id) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// when device changes or dialog opens, update local value
|
// when device changes or dialog opens, update local value
|
||||||
watch(
|
watch(
|
||||||
() => props.device,
|
() => props.device,
|
||||||
(dev) => {
|
(dev) => {
|
||||||
|
console.log(dev?.assigned_users)
|
||||||
name.value = dev?.devicename ?? ''
|
name.value = dev?.devicename ?? ''
|
||||||
|
if (props.initialUserIds && props.initialUserIds.length) {
|
||||||
|
selectedUserIds.value = [...props.initialUserIds]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const usernames = (dev?.assigned_users ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (usernames.length && props.allUsers?.length) {
|
||||||
|
selectedUserIds.value = usernames
|
||||||
|
.map(usernameToId)
|
||||||
|
.filter((x): x is string => !!x)
|
||||||
|
} else {
|
||||||
|
selectedUserIds.value = []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
@@ -52,7 +80,7 @@ function onSave() {
|
|||||||
:open="props.modelValue"
|
:open="props.modelValue"
|
||||||
@update:open="(v: boolean) => emit('update:modelValue', v)"
|
@update:open="(v: boolean) => emit('update:modelValue', v)"
|
||||||
>
|
>
|
||||||
<DialogContent class="sm:max-w-[425px]">
|
<DialogContent class="sm:min-w-[500px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit device</DialogTitle>
|
<DialogTitle>Edit device</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -71,7 +99,7 @@ function onSave() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-4 items-center gap-4">
|
<div class="grid grid-cols-4 items-center gap-4">
|
||||||
<Label for="users" class="text-right">Allowed users</Label>
|
<Label for="users" class="text-right">Allowed users</Label>
|
||||||
<AssignDevice id="users"/>
|
<AssignDevice id="users" v-model="selectedUserIds" :all-users="props.allUsers" class="col-span-3 w-full"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user