chore: update MetadataEditor, MetadataEditorDialog, and PersonEditor to setup composition API

This commit is contained in:
aweebs 2023-03-01 12:13:36 -08:00 committed by Fernando Fernández
parent 5fb2054336
commit 7d4278ccdf
4 changed files with 435 additions and 472 deletions

View File

@ -33,7 +33,7 @@
</v-list>
</v-menu>
<metadata-editor-dialog
v-if="metadataDialog"
v-if="item.Id"
v-model:dialog="metadataDialog"
:item-id="item.Id" />
</div>

View File

@ -1,137 +1,151 @@
<template>
<v-card height="100%" class="d-flex flex-column metadata-editor">
<v-card-title>{{ $t('editMetadata') }}</v-card-title>
<v-card
v-if="metadata"
height="100%"
class="d-flex flex-column metadata-editor">
<v-card-title>{{ t('editMetadata') }}</v-card-title>
<v-card-subtitle class="pb-3">
{{ metadata.Path }}
</v-card-subtitle>
<v-divider />
<v-card-text class="pa-0 flex-grow-1">
<v-tabs v-model="tabName" :vertical="!$vuetify.display.mobile">
<v-tab href="#general">{{ $t('general') }}</v-tab>
<v-tab href="#details">{{ $t('details') }}</v-tab>
<v-tab href="#castAndCrew">{{ $t('castAndCrew') }}</v-tab>
<v-tab href="#images">{{ $t('images') }}</v-tab>
<v-card-text
class="pa-0 flex-grow-1"
:class="{
'd-flex': !$vuetify.display.mobile,
'flex-row': !$vuetify.display.mobile
}">
<v-tabs
v-model="tabName"
:direction="$vuetify.display.mobile ? 'horizontal' : 'vertical'">
<v-tab value="general">{{ t('general') }}</v-tab>
<v-tab value="details">{{ t('details') }}</v-tab>
<v-tab value="castAndCrew">{{ t('castAndCrew') }}</v-tab>
<v-tab value="images">{{ t('images') }}</v-tab>
</v-tabs>
<v-window v-model="tabName" class="pa-2 flex-fill">
<v-window-item value="general">
<v-text-field
v-model="metadata.Name"
variant="outlined"
:label="t('metadata.title')" />
<v-text-field
v-model="metadata.OriginalTitle"
variant="outlined"
:label="t('originalTitle')" />
<v-text-field
v-model="metadata.ForcedSortName"
variant="outlined"
:label="t('sortTitle')" />
<v-text-field
v-model="metadata.Taglines"
variant="outlined"
:label="t('tagline')" />
<v-textarea
v-model="metadata.Overview"
variant="outlined"
no-resize
rows="4"
:label="t('overview')" />
</v-window-item>
<v-window-item value="details">
<date-input
:value="dateCreated"
:label="t('dateAdded')"
@update:date="
(value) => formatAndAssignDate('DateCreated', value)
" />
<v-row>
<v-col sm="6" cols="12">
<v-text-field
v-model="metadata.CommunityRating"
variant="outlined"
:label="t('communityRating')" />
</v-col>
<v-col sm="6" cols="12">
<v-text-field
v-model="metadata.CriticRating"
variant="outlined"
:label="t('criticRating')" />
</v-col>
</v-row>
<v-tabs v-model="tabName" class="pa-3">
<v-tab value="general">
<v-text-field
v-model="metadata.Name"
variant="outlined"
:label="$t('metadata.title')" />
<v-text-field
v-model="metadata.OriginalTitle"
variant="outlined"
:label="$t('originalTitle')" />
<v-text-field
v-model="metadata.ForcedSortName"
variant="outlined"
:label="$t('sortTitle')" />
<v-text-field
v-model="metadata.Taglines"
variant="outlined"
:label="$t('tagline')" />
<v-textarea
v-model="metadata.Overview"
variant="outlined"
no-resize
rows="4"
:label="$t('overview')" />
</v-tab>
<v-tab value="details">
<date-input
:value="dateCreated"
:label="$t('dateAdded')"
@update:date="(value) => saveDate('DateCreated', value)" />
<v-row>
<v-col sm="6" cols="12">
<v-text-field
v-model="metadata.CommunityRating"
variant="outlined"
:label="$t('communityRating')" />
</v-col>
<v-col sm="6" cols="12">
<v-text-field
v-model="metadata.CriticRating"
variant="outlined"
:label="$t('criticRating')" />
</v-col>
</v-row>
<date-input
:value="premiereDate"
:label="$t('releaseDate')"
@update:date="(value) => saveDate('PremiereDate', value)" />
<v-text-field
v-model="metadata.ProductionYear"
variant="outlined"
:label="$t('year')" />
<v-text-field
v-model="metadata.OfficialRating"
variant="outlined"
:label="$t('parentalRating')" />
<v-text-field
v-model="metadata.CustomRating"
variant="outlined"
:label="$t('customRating')" />
<v-combobox
v-model="metadata.Genres"
v-model:search-input="search"
:items="genres"
:label="$t('genres')"
hide-selected
multiple
variant="outlined"
small-chips>
<template #no-data>
<v-list-item>
<v-list-item-title>
{{ $t('metadataNoResultsMatching', { search: search }) }}
</v-list-item-title>
</v-list-item>
</template>
</v-combobox>
<v-combobox
v-model="metadata.Tags"
v-model:search-input="search"
:items="genres"
:label="$t('tags')"
hide-selected
multiple
variant="outlined"
small-chips>
<template #no-data>
<v-list-item>
<v-list-item-title>
{{ $t('metadataNoResultsMatching', { search: search }) }}
</v-list-item-title>
</v-list-item>
</template>
</v-combobox>
</v-tab>
<v-tab value="castAndCrew">
<v-list lines="two">
<v-list-item @click="(e) => handlePersonEdit()">
<date-input
:value="premiereDate"
:label="t('releaseDate')"
@update:date="
(value) => formatAndAssignDate('PremiereDate', value)
" />
<v-text-field
v-model="metadata.ProductionYear"
variant="outlined"
:label="t('year')" />
<v-text-field
v-model="metadata.OfficialRating"
variant="outlined"
:label="t('parentalRating')" />
<v-text-field
v-model="metadata.CustomRating"
variant="outlined"
:label="t('customRating')" />
<v-combobox
v-model="metadata.Genres"
:items="genres"
:label="t('genres')"
hide-selected
multiple
variant="outlined"
@update:search="(s) => (search = s)">
<template #no-data>
<v-list-item>
<v-list-item-title>
{{ $t('addNewPerson') }}
{{ t('metadataNoResultsMatching', { search: search }) }}
</v-list-item-title>
<v-list-item-action>
</v-list-item>
</template>
</v-combobox>
<v-combobox
v-model="metadata.Tags"
:items="genres"
:label="t('tags')"
hide-selected
multiple
variant="outlined"
@update:search="(s) => (search = s)">
<template #no-data>
<v-list-item>
<v-list-item-title>
{{ t('metadataNoResultsMatching', { search: search }) }}
</v-list-item-title>
</v-list-item>
</template>
</v-combobox>
</v-window-item>
<v-window-item value="castAndCrew">
<v-list lines="two">
<v-list-item :title="t('addNewPerson')" @click="onPersonAdd">
<template #append>
<v-avatar>
<v-icon>
<i-mdi-plus-circle />
</v-icon>
</v-list-item-action>
</v-list-item>
<v-list-item
v-for="(item, i) in metadata.People"
:key="`${item.Id}-${i}`"
@click="handlePersonEdit(item)">
</v-avatar>
</template>
</v-list-item>
<v-list-item
v-for="(item, i) in metadata.People"
:key="`${item.Id}-${i}`"
:title="item.Name ?? undefined"
:subtitle="(item.Role || item.Type) ?? undefined"
@click="onPersonEdit(item)">
<template #prepend>
<v-avatar>
<v-img
v-if="item.PrimaryImageTag"
v-if="item.Id && item.PrimaryImageTag"
:src="
$remote.sdk.api?.getItemImageUrl(
item.Id || '',
remote.sdk.api?.getItemImageUrl(
item.Id,
ImageType.Primary
)
" />
@ -139,25 +153,25 @@
<i-mdi-account />
</v-icon>
</v-avatar>
<v-list-item-title>
{{ item.Name }}
</v-list-item-title>
<v-list-item-subtitle class="mt-1">
{{ item.Role || item.Type }}
</v-list-item-subtitle>
<v-list-item-action @click.stop="handlePersonDel(i)">
</template>
<template #append>
<v-avatar @click.stop="onPersonDel(i)">
<v-icon>
<i-mdi-delete />
</v-icon>
</v-list-item-action>
</v-list-item>
</v-list>
</v-tab>
<v-tab value="images">
<image-editor :metadata="metadata" />
</v-tab>
</v-tabs>
</v-tabs>
</v-avatar>
</template>
</v-list-item>
</v-list>
<person-editor
:person="person"
@update:person="onPersonSave"
@close="person = undefined" />
</v-window-item>
<v-window-item value="images">
<image-editor :metadata="metadata" />
</v-window-item>
</v-window>
</v-card-text>
<v-divider />
@ -172,8 +186,8 @@
width="8em"
color="secondary"
class="mr-1"
@click="$emit('cancel')">
{{ $t('cancel') }}
@click="emit('cancel')">
{{ t('cancel') }}
</v-btn>
<v-btn
variant="flat"
@ -181,19 +195,15 @@
color="primary"
:loading="loading"
@click="saveMetadata">
{{ $t('save') }}
{{ t('save') }}
</v-btn>
</v-card-actions>
<person-editor
v-model:dialog="dialog"
:person="person"
@update:person="handlePersonUpdate"
@update:dialog="handleDialogUpdate" />
</v-card>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { pick, set } from 'lodash-es';
import { AxiosError } from 'axios';
import {
@ -206,241 +216,224 @@ import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api'
import { getGenresApi } from '@jellyfin/sdk/lib/utils/api/genres-api';
import { getItemUpdateApi } from '@jellyfin/sdk/lib/utils/api/item-update-api';
import { format, formatISO } from 'date-fns';
import { useDateFns, useSnackbar } from '@/composables';
import { useDateFns, useRemote, useSnackbar } from '@/composables';
export default defineComponent({
props: {
itemId: {
type: String,
default: ''
},
forceRefresh: {
type: Boolean,
default: false
}
},
setup() {
return {
useSnackbar,
ImageType
};
},
data() {
return {
metadata: {} as BaseItemDto,
menu: false,
dialog: false,
person: null as BaseItemPerson | null,
genres: [] as BaseItemDto[] | null | undefined,
search: '',
loading: false,
tabName: null
};
},
computed: {
premiereDate(): string {
if (!this.metadata.PremiereDate) {
return '';
}
const props = defineProps<{ itemId: string }>();
return useDateFns(
format,
new Date(this.metadata.PremiereDate),
'yyyy-MM-dd'
).value;
},
dateCreated(): string {
if (!this.metadata.DateCreated) {
return '';
}
const emit = defineEmits<{
(e: 'save'): void;
(e: 'update:forceRefresh'): void;
(e: 'cancel'): void;
}>();
return useDateFns(
format,
new Date(this.metadata.DateCreated),
'yyyy-MM-dd'
).value;
}
},
watch: {
itemId(): void {
this.getData();
},
forceRefresh(): void {
this.getData();
this.$emit('update:forceRefresh', false);
}
},
created() {
this.getData();
},
methods: {
async getData(): Promise<void> {
await this.fetchItemInfo();
const { t } = useI18n();
const remote = useRemote();
if (!this.metadata.Id) {
return;
}
const metadata = ref<BaseItemDto>();
const menu = ref(false);
const person = ref<BaseItemPerson>();
const genres = ref<string[]>([]);
const search = ref('');
const loading = ref(false);
const tabName = ref<string>();
const ancestors = await this.$remote.sdk
.newUserApi(getLibraryApi)
.getAncestors({
itemId: this.metadata.Id,
userId: this.$remote.auth.currentUserId
});
const libraryInfo = ancestors.data.find(
(index) => index.Type === 'CollectionFolder'
);
if (!libraryInfo || !libraryInfo.Id) {
return;
}
this.getGenres(libraryInfo.Id);
},
async fetchItemInfo(): Promise<void> {
const itemInfo = (
await this.$remote.sdk.newUserApi(getUserLibraryApi).getItem({
userId: this.$remote.auth.currentUserId || '',
itemId: this.itemId
})
).data;
this.$data.metadata = itemInfo;
},
async getGenres(parentId: string): Promise<void> {
this.genres =
(
await this.$remote.sdk.newUserApi(getGenresApi).getGenres({
parentId
})
).data.Items?.map((index) => index.Name).filter(
(genre): genre is string => !!genre
) ?? [];
},
async saveMetadata(): Promise<void> {
const item = pick(this.metadata, [
'Id',
'Name',
'OriginalTitle',
'ForcedSortName',
'CommunityRating',
'CriticRating',
'IndexNumber',
'AirsBeforeSeasonNumber',
'AirsAfterSeasonNumber',
'AirsBeforeEpisodeNumber',
'ParentIndexNumber',
'DisplayOrder',
'Album',
'AlbumArtists',
'ArtistItems',
'Overview',
'Status',
'AirDays',
'AirTime',
'Genres',
'Tags',
'Studios',
'PremiereDate',
'DateCreated',
'EndDate',
'ProductionYear',
'AspectRatio',
'Video3DFormat',
'OfficialRating',
'CustomRating',
'People',
'LockData',
'LockedFields',
'ProviderIds',
'PreferredMetadataLanguage',
'PreferredMetadataCountryCode',
'Taglines'
]);
try {
this.loading = true;
if (!this.metadata.Id) {
throw new Error('Expected metadata to have id');
}
await this.$remote.sdk.newUserApi(getItemUpdateApi).updateItem({
itemId: this.metadata.Id,
baseItemDto: item
});
this.$emit('save');
this.loading = false;
this.useSnackbar(this.$t('saved'), 'success');
} catch (error) {
// TODO: This whole block should be removed - we should verify that the data is correct client-side before posting to server
// not expecting bad request messages.
// TODO: Revise similar blocks like this through the entire codebase.
let errorMessage = this.$t('unexpectedError');
if (error instanceof AxiosError && error.response?.status === 400) {
errorMessage = this.$t('badRequest');
}
this.useSnackbar(errorMessage, 'error');
}
},
saveDate(key: string, date: string): void {
this.menu = false;
set(this.metadata, key, formatISO(new Date(date)));
},
handlePersonEdit(item: BaseItemPerson): void {
this.person = item;
this.dialog = true;
},
handlePersonUpdate(item: BaseItemPerson): void {
if (!this.metadata.People) {
this.metadata.People = [];
}
const target = this.metadata.People?.find(
(person) => person.Id === item.Id
);
if (target) {
Object.assign(target, item);
} else {
this.metadata.People.push(item);
}
},
handleDialogUpdate(result: boolean): void {
this.dialog = result;
},
handlePersonDel(index: number): void {
(this.metadata.People ?? []).splice(index, 1);
}
const premiereDate = computed(() => {
if (!metadata.value?.PremiereDate) {
return '';
}
return useDateFns(format, new Date(metadata.value.PremiereDate), 'yyyy-MM-dd')
.value;
});
const dateCreated = computed(() => {
if (!metadata.value?.DateCreated) {
return '';
}
return useDateFns(format, new Date(metadata.value.DateCreated), 'yyyy-MM-dd')
.value;
});
/**
* Fetch data ancestors for the current item
*/
async function getData(): Promise<void> {
const itemInfo = (
await remote.sdk.newUserApi(getUserLibraryApi).getItem({
userId: remote.auth.currentUserId ?? '',
itemId: props.itemId
})
).data;
metadata.value = itemInfo;
if (!metadata.value?.Id) {
return;
}
const ancestors = await remote.sdk.newUserApi(getLibraryApi).getAncestors({
userId: remote.auth.currentUserId ?? '',
itemId: metadata.value.Id
});
const libraryInfo = ancestors.data.find(
(index) => index.Type === 'CollectionFolder'
);
if (!libraryInfo?.Id) {
return;
}
getGenres(libraryInfo.Id);
}
/**
* Get genres associated with the current item
*/
async function getGenres(parentId: string): Promise<void> {
genres.value =
(
await remote.sdk.newUserApi(getGenresApi).getGenres({
parentId
})
).data.Items?.map((index) => index.Name).filter(
(genre): genre is string => !!genre
) ?? [];
}
/**
* Save metadata for the current item
*/
async function saveMetadata(): Promise<void> {
if (!metadata.value || !metadata.value.Id) {
return;
}
const item = pick(metadata.value, [
'Id',
'Name',
'OriginalTitle',
'ForcedSortName',
'CommunityRating',
'CriticRating',
'IndexNumber',
'AirsBeforeSeasonNumber',
'AirsAfterSeasonNumber',
'AirsBeforeEpisodeNumber',
'ParentIndexNumber',
'DisplayOrder',
'Album',
'AlbumArtists',
'ArtistItems',
'Overview',
'Status',
'AirDays',
'AirTime',
'Genres',
'Tags',
'Studios',
'PremiereDate',
'DateCreated',
'EndDate',
'ProductionYear',
'AspectRatio',
'Video3DFormat',
'OfficialRating',
'CustomRating',
'People',
'LockData',
'LockedFields',
'ProviderIds',
'PreferredMetadataLanguage',
'PreferredMetadataCountryCode',
'Taglines'
]);
try {
loading.value = true;
if (!metadata.value.Id) {
throw new Error('Expected metadata to have id');
}
await remote.sdk.newUserApi(getItemUpdateApi).updateItem({
itemId: metadata.value?.Id,
baseItemDto: item
});
emit('save');
useSnackbar(t('saved'), 'success');
} catch (error) {
// TODO: This whole block should be removed - we should verify that the data is correct client-side before posting to server
// not expecting bad request messages.
// TODO: Revise similar blocks like this through the entire codebase.
let errorMessage = t('unexpectedError');
if (error instanceof AxiosError && error.response?.status === 400) {
errorMessage = t('badRequest');
}
useSnackbar(errorMessage, 'error');
} finally {
loading.value = false;
}
}
/**
* Formats and updates dates
*/
function formatAndAssignDate(key: keyof BaseItemDto, date: string): void {
if (!metadata.value) {
return;
}
menu.value = false;
set(metadata.value, key, formatISO(new Date(date)));
}
/**
* Handle adding a person
*/
function onPersonAdd(): void {
onPersonEdit({});
}
/**
* Handle editing a person
*/
function onPersonEdit(item: BaseItemPerson): void {
person.value = item;
}
/**
* Handle saving a person after editing
*/
function onPersonSave(item: BaseItemPerson): void {
if (!metadata.value?.People) {
return;
}
if (!item.Id) {
// undefined id means that the person was newly added
metadata.value.People.push(item);
} else {
metadata.value.People = metadata.value.People.map((p) =>
p.Id === item.Id ? item : p
);
}
person.value = undefined;
}
/**
* Handle deleting a person
*/
function onPersonDel(index: number): void {
if (!metadata.value?.People) {
return;
}
metadata.value.People.splice(index, 1);
}
watch(() => props.itemId, getData, { immediate: true });
</script>
<style lang="scss" scoped>
.person-icon {
background-color: rgb(var(--v-secondary-darken1));
}
:deep(.v-card__text) {
overflow: hidden;
}
:deep(.v-tabs) {
height: 100%;
}
:deep(.v-tabs-bar) {
overflow: hidden;
border-right: 1px solid var(--v-secondary-lighten1);
}
:deep(.v-tabs-items) {
overflow-y: scroll;
}
:deep(.v-card__actions) {
width: 100%;
}
</style>

View File

@ -3,42 +3,24 @@
content-class="metadata-dialog"
:model-value="dialog"
:fullscreen="$vuetify.display.mobile"
width="50vw"
@click:outside="$emit('update:dialog', false)">
<metadata-editor
v-model:force-refresh="forceRefresh"
:item-id="itemId"
@cancel="close"
@save="close" />
@update:model-value="close">
<metadata-editor :item-id="itemId" @cancel="close" @save="close" />
</v-dialog>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script setup lang="ts">
defineProps<{ dialog: boolean; itemId: string }>();
export default defineComponent({
props: {
dialog: {
type: Boolean,
required: true
},
itemId: {
type: String,
default: ''
}
},
data() {
return {
forceRefresh: false
};
},
methods: {
close(): void {
this.forceRefresh = true;
this.$emit('update:dialog', false);
}
}
});
const emit = defineEmits<{
(e: 'update:dialog', isOpen: boolean): void;
}>();
/**
* Close the dialog
*/
function close(): void {
emit('update:dialog', false);
}
</script>
<style lang="scss" scoped>

View File

@ -1,14 +1,17 @@
<template>
<v-dialog :model-value="dialog" max-width="30%" @click:outside="handleCancel">
<v-dialog
max-width="30%"
:model-value="person !== undefined"
@update:model-value="emit('close')">
<v-card>
<v-card-title>{{ $t('editPerson') }}</v-card-title>
<v-card-title>{{ t('editPerson') }}</v-card-title>
<v-divider />
<v-card-text class="pa-3">
<v-row>
<v-col cols="4">
<v-avatar size="160" class="ml-2">
<v-img
v-if="person && person.PrimaryImageTag"
v-if="person?.Id && person?.PrimaryImageTag"
:src="
$remote.sdk.api?.getItemImageUrl(person.Id, ImageType.Primary)
" />
@ -18,21 +21,23 @@
</v-avatar>
</v-col>
<v-col>
<v-form @submit.prevent="handleSubmit">
<v-form v-if="editState" @submit.prevent="onSubmit">
<v-text-field
v-model="editState.Name"
variant="outlined"
:label="$t('name')" />
:label="t('name')" />
<v-select
v-model="editState.Type"
:items="options"
:label="$t('type')"
:label="t('type')"
item-title="text"
item-value="value"
variant="outlined" />
<v-text-field
v-if="editState.Type === 'Actor'"
v-model="editState.Role"
variant="outlined"
:label="$t('role')" />
:label="t('role')" />
</v-form>
</v-col>
</v-row>
@ -45,74 +50,57 @@
'justify-center': $vuetify.display.mobile
}">
<v-spacer />
<v-btn variant="flat" width="8em" class="mr-1" @click="handleCancel">
{{ $t('cancel') }}
<v-btn variant="flat" width="8em" class="mr-1" @click="emit('close')">
{{ t('cancel') }}
</v-btn>
<v-btn variant="flat" width="8em" color="primary" type="submit">
{{ $t('save') }}
<v-btn variant="flat" width="8em" color="primary" @click="onSubmit">
{{ t('save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { BaseItemPerson, ImageType } from '@jellyfin/sdk/lib/generated-client';
export default defineComponent({
props: {
person: {
type: Object,
default: (): BaseItemPerson => ({
Name: '',
Type: '',
Role: ''
})
},
dialog: {
type: Boolean,
default: false
}
},
setup() {
return { ImageType };
},
data() {
return {
editState: {} as BaseItemPerson,
options: [
{ text: this.$t('actor'), value: 'Actor' },
{ text: this.$t('composer'), value: 'Composer' },
{ text: this.$t('director'), value: 'Director' },
{ text: this.$t('guestStar'), value: 'GuestStar' },
{ text: this.$t('producer'), value: 'Producer' },
{ text: this.$t('writer'), value: 'Writer' }
]
};
},
watch: {
person(value: BaseItemPerson): void {
if (!value) {
return;
}
const props = defineProps<{ person: BaseItemPerson | undefined }>();
this.editState = value;
}
const emit = defineEmits<{
(e: 'update:person', person: BaseItemPerson): void;
(e: 'close'): void;
}>();
const { t } = useI18n();
const editState = ref<BaseItemPerson>();
const options = computed(() => [
{ text: t('actor'), value: 'Actor' },
{ text: t('composer'), value: 'Composer' },
{ text: t('director'), value: 'Director' },
{ text: t('guestStar'), value: 'GuestStar' },
{ text: t('producer'), value: 'Producer' },
{ text: t('writer'), value: 'Writer' }
]);
watch(
() => props.person,
(person) => {
editState.value = { ...person };
},
methods: {
handleSubmit(): void {
this.$emit('update:person', this.editState);
this.$emit('update:dialog', false);
this.reset();
},
handleCancel(): void {
this.$emit('update:dialog', false);
this.reset();
},
reset(): void {
this.editState = { Name: '', Type: '', Role: '' };
}
{ immediate: true }
);
/**
* Handles saving person changes
*/
function onSubmit(): void {
if (!editState.value) {
return;
}
});
emit('update:person', editState.value);
}
</script>