Skip to content
Open
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
Expand Up @@ -2,23 +2,23 @@

## Rationale

Display and edit a single client in the client list.
Display and edit a registered Client in the deployment Client list.

## Goals

Allow users to view client details and edit client name and URL.
Allow users to view Client details and explicitly save its name, management URL, or configuration.

## Key Concepts

Client management, health status.

## Specification

Shows client name, ID, URL, and status. Has edit mode with inputs for name and URL, save/cancel buttons.
Shows Client name, ID, management URL, and health status. Name and URL changes use an explicit save action. Configuration changes use a JSON dialog.

## Implementation

Uses reactive editing state, emits updated on save.
Updates only an existing Client and emits `updated` after a successful save.

### Props

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
display: flex;
flex-direction: column;
gap: sys-var(space, xs);
flex: 1;
min-width: 0;
}

&__item-name {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Client } from '@inkcre/core'
import ClientCard from './clientCard.vue'

vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))

const stubs = {
InkInput: defineComponent({
props: ['modelValue'],
emits: ['update:modelValue'],
template:
'<input :value="modelValue ?? \'\'" @input="$emit(\'update:modelValue\', $event.target.value)" />',
}),
InkButton: defineComponent({
props: ['text', 'loading'],
emits: ['click'],
template: '<button @click="$emit(\'click\')">{{ text }}</button>',
}),
InkDialog: defineComponent({ template: '<div><slot /></div>' }),
InkJsonEditor: true,
}

describe('ClientCard', () => {
beforeEach(() => {
vi.stubGlobal('alert', vi.fn())
vi.spyOn(console, 'error').mockImplementation(() => undefined)
})

afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})

it('explicitly updates an existing Client without an upsert lifecycle', async () => {
const single = vi.fn().mockResolvedValue({ data: {}, error: null, status: 200 })
const select = vi.fn(() => ({ single }))
const eq = vi.fn(() => ({ select }))
const update = vi.spyOn(Client.dbApi, 'update').mockReturnValue({ eq } as never)
const client = Client.parse({
id: '00000000-0000-4000-8000-000000000003',
name: 'Old name',
rest_api_url: 'https://old.example.test/',
})
const wrapper = mount(ClientCard, {
props: { client, status: 'unknown' },
global: { stubs },
})

const inputs = wrapper.findAll('input')
await inputs[0].setValue('Renamed Client')
await inputs[1].setValue('https://new.example.test/')
const save = wrapper.findAll('button').find((button) => button.text() === 'settings.saveConfig')
await save?.trigger('click')
await flushPromises()

expect(update).toHaveBeenCalledWith({
name: 'Renamed Client',
rest_api_url: 'https://new.example.test/',
})
expect(eq).toHaveBeenCalledWith('id', client.id)
expect(single).toHaveBeenCalledOnce()
expect(wrapper.emitted('updated')).toHaveLength(1)
})

it('rejects an invalid management URL before writing the Client row', async () => {
const update = vi.spyOn(Client.dbApi, 'update')
const client = Client.parse({
id: '00000000-0000-4000-8000-000000000003',
name: 'Core',
rest_api_url: 'https://core.example.test/',
})
const wrapper = mount(ClientCard, {
props: { client, status: 'unknown' },
global: { stubs },
})

await wrapper.findAll('input')[1].setValue('not a URL')
const save = wrapper.findAll('button').find((button) => button.text() === 'settings.saveConfig')
await save?.trigger('click')
await flushPromises()

expect(update).not.toHaveBeenCalled()
expect(alert).toHaveBeenCalledOnce()
})
})
68 changes: 52 additions & 16 deletions apps/client-web/src/components/client/clientCard/clientCard.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { InkButton, InkInput, InkDialog, InkJsonEditor } from '@inkcre/ui-web'
import { Client, CreateClientForm } from '@inkcre/core'
import { APIError, Client } from '@inkcre/core'
import { clientCardProps, clientCardEmits } from './clientCard'

const props = defineProps(clientCardProps)
Expand All @@ -11,36 +11,64 @@ const emit = defineEmits(clientCardEmits)
const { t } = useI18n()

const configPopupOpen = ref(false)
const configModel = computed({
get: () => JSON.stringify(props.client.config ?? {}, null, 2),
set: (newValue: string) => {
props.client.config = JSON.parse(newValue)
},
})
const configModel = ref('')
const clientSaving = ref(false)

const isJsonObject = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

const saveClient = async () => {
clientSaving.value = true
try {
const form = new CreateClientForm(props.client)
await form.upsert()
const updatedClient = Client.parse({
...props.client,
rest_api_url: props.client.rest_api_url || null,
})
const response = await Client.dbApi
.update({
name: updatedClient.name,
rest_api_url: updatedClient.rest_api_url,
})
.eq('id', props.client.id)
.select()
.single()
if (response.error) {
throw new APIError(
`Client update failed: ${response.error.message}`,
response.status,
response.error
)
}
emit('updated')
} catch (error) {
console.error('Failed to update client:', error)
alert('Failed to update client')
alert(error instanceof Error ? error.message : t('settings.saveError'))
} finally {
clientSaving.value = false
}
}

const onEditConfigClick = () => {
configModel.value = JSON.stringify(props.client.config ?? {}, null, 2)
configPopupOpen.value = true
}

const onConfirmConfig = async () => {
const previousConfig = props.client.config
try {
const nextConfig: unknown = JSON.parse(configModel.value)
if (!isJsonObject(nextConfig)) {
throw new TypeError('Client configuration must be a JSON object.')
}
props.client.config = nextConfig
await props.client.saveConfig()
emit('updated')
configPopupOpen.value = false
} catch (error) {
props.client.config = previousConfig
console.error('Failed to update client config:', error)
alert('Failed to update client config')
alert(error instanceof Error ? error.message : t('settings.saveError'))
}
}

Expand All @@ -57,18 +85,26 @@ const getStatusText = (status: 'online' | 'offline' | 'unknown') => {
<template>
<div class="client-card">
<div class="client-card__item-info">
<InkInput v-model="client.name" type="inline" @confirm="saveClient" />
<InkInput v-model="client.name" type="inline" />
<span class="client-card__item-id">{{ client.id }}</span>
<InkInput v-model="client.rest_api_url" type="inline" @confirm="saveClient" />
<InkButton :text="t('client.editConfig')" size="sm" @click="onEditConfigClick" />
<InkInput v-model="client.rest_api_url" type="inline" />
<div class="client-card__edit-actions">
<InkButton
:text="t('settings.saveConfig')"
size="sm"
:loading="clientSaving"
@click="saveClient"
/>
<InkButton :text="t('settings.clientConfig')" size="sm" @click="onEditConfigClick" />
</div>
</div>
<span :class="['client-card__item-status', `client-card__item-status--${status}`]">
{{ getStatusText(status) }}
</span>

<InkDialog
v-model="configPopupOpen"
:title="t('client.editConfigTitle')"
:title="t('settings.clientConfig')"
@confirm="onConfirmConfig"
>
<InkJsonEditor v-model="configModel" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
.client-list {
display: flex;
flex-direction: column;
gap: sys-var(space, md);
gap: sys-var(space, lg);
padding: sys-var(space, lg);
background-color: sys-var(color, surface, base);
border: 1px solid sys-var(color, border, base);

&__header {
display: flex;
Expand All @@ -17,6 +20,11 @@
color: sys-var(color, text, base);
}

&__notice {
margin: sys-var(space, xs) 0 0;
color: sys-var(color, text, subtle);
}

&__actions {
display: flex;
gap: sys-var(space, sm);
Expand Down Expand Up @@ -85,4 +93,11 @@
text-align: center;
padding: sys-var(space, xl);
}

&__error {
padding: sys-var(space, md);
color: sys-var(color, danger, base);
background-color: sys-var(color, danger, surface);
border: 1px solid sys-var(color, danger, base);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Client } from '@inkcre/core'
import ClientList from './clientList.vue'

vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))

const stubs = {
InkButton: defineComponent({
props: ['text'],
emits: ['click'],
template: '<button @click="$emit(\'click\')">{{ text }}</button>',
}),
InkLoading: defineComponent({ template: '<span data-test="loading" />' }),
ClientCard: defineComponent({
props: ['client', 'status'],
emits: ['updated'],
template: '<article data-test="client-card">{{ client.name }}:{{ status }}</article>',
}),
}

describe('ClientList', () => {
afterEach(() => vi.restoreAllMocks())

it('presents registered Clients as a deployment-wide scope', async () => {
const client = Client.parse({
id: '00000000-0000-4000-8000-000000000002',
name: 'Core',
rest_api_url: 'https://core.example.test/',
})
vi.spyOn(Client, 'list').mockResolvedValue([client])

const wrapper = mount(ClientList, { global: { stubs } })
await flushPromises()

expect(wrapper.get('h2').text()).toBe('settings.allClientsScope')
expect(wrapper.text()).toContain('settings.allClientsNotice')
expect(wrapper.get('[data-test="client-card"]').text()).toBe('Core:unknown')
})

it('shows a load failure instead of claiming that no Clients exist', async () => {
vi.spyOn(Client, 'list').mockRejectedValue(new Error('Database connection refused'))

const wrapper = mount(ClientList, { global: { stubs } })
await flushPromises()

expect(wrapper.get('[role="alert"]').text()).toContain('Database connection refused')
expect(wrapper.text()).not.toContain('client.noClients')
})
})
Loading
Loading