Add Tag Edit Modal and UI

This commit is contained in:
Gregor Vostrak
2026-02-10 13:19:30 +01:00
parent 215957104f
commit 2c4af95ee3
7 changed files with 158 additions and 6 deletions
+32
View File
@@ -88,3 +88,35 @@ test('test that multiple tags can be created via API and displayed in the table'
await expect(page.getByTestId('tag_table')).toContainText(tagName1);
await expect(page.getByTestId('tag_table')).toContainText(tagName2);
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Tags Restrictions', () => {
test('employee can view tags but cannot create', async ({ ctx, employee }) => {
const tagName = 'EmpViewTag ' + Math.floor(Math.random() * 10000);
await createTagViaApi(ctx, { name: tagName });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/tags');
await expect(employee.page.getByTestId('tags_view')).toBeVisible({ timeout: 10000 });
// Employee can see the tag (tags are visible to all members with tags:view)
await expect(employee.page.getByText(tagName)).toBeVisible({ timeout: 10000 });
// Employee cannot see Create Tag button
await expect(employee.page.getByRole('button', { name: 'Create Tag' })).not.toBeVisible();
});
test('employee cannot see edit/delete actions on tags', async ({ ctx, employee }) => {
const tagName = 'EmpActionsTag ' + Math.floor(Math.random() * 10000);
await createTagViaApi(ctx, { name: tagName });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/tags');
await expect(employee.page.getByText(tagName)).toBeVisible({ timeout: 10000 });
// Actions button should not be visible for employee
const actionsButton = employee.page.locator(`[aria-label='Actions for Tag ${tagName}']`);
await expect(actionsButton).not.toBeVisible();
});
});
@@ -0,0 +1,75 @@
<script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useTagsStore } from '@/utils/useTags';
import type { Tag, UpdateTagBody } from '@/packages/api/src';
const { updateTag } = useTagsStore();
const show = defineModel('show', { default: false });
const saving = ref(false);
const props = defineProps<{
tag: Tag;
}>();
const tagBody = ref<UpdateTagBody>({
name: props.tag.name,
});
async function submit() {
saving.value = true;
try {
await updateTag({ tagId: props.tag.id, tagBody: tagBody.value });
show.value = false;
} finally {
saving.value = false;
}
}
const tagNameInput = ref<HTMLInputElement | null>(null);
useFocus(tagNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Update Tag </span>
</div>
</template>
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1">
<TextInput
id="tagName"
ref="tagNameInput"
v-model="tagBody.name"
type="text"
placeholder="Tag Name"
class="mt-1 block w-full"
required
autocomplete="tagName"
@keydown.enter="submit()" />
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Update Tag
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { TrashIcon } from '@heroicons/vue/20/solid';
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
import { canDeleteTags, canUpdateTags } from '@/utils/permissions';
import type { Tag } from '@/packages/api/src';
import {
DropdownMenu,
@@ -9,6 +10,7 @@ import {
} from '@/Components/ui/dropdown-menu';
const emit = defineEmits<{
edit: [];
delete: [];
}>();
const props = defineProps<{
@@ -38,6 +40,16 @@ const props = defineProps<{
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[150px]" align="end">
<DropdownMenuItem
v-if="canUpdateTags()"
:aria-label="'Edit Tag ' + props.tag.name"
data-testid="tag_edit"
class="flex items-center space-x-3 cursor-pointer"
@click="emit('edit')">
<PencilSquareIcon class="w-5 text-icon-active" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="canDeleteTags()"
:aria-label="'Delete Tag ' + props.tag.name"
data-testid="tag_delete"
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
@@ -2,13 +2,17 @@
import type { Tag } from '@/packages/api/src';
import { useTagsStore } from '@/utils/useTags';
import TagMoreOptionsDropdown from '@/Components/Common/Tag/TagMoreOptionsDropdown.vue';
import TagEditModal from '@/Components/Common/Tag/TagEditModal.vue';
import TableRow from '@/Components/TableRow.vue';
import { canDeleteTags } from '@/utils/permissions';
import { canDeleteTags, canUpdateTags } from '@/utils/permissions';
import { ref } from 'vue';
const props = defineProps<{
tag: Tag;
}>();
const showTagEditModal = ref(false);
function deleteTag() {
useTagsStore().deleteTag(props.tag.id);
}
@@ -25,10 +29,12 @@ function deleteTag() {
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<TagMoreOptionsDropdown
v-if="canDeleteTags()"
v-if="canDeleteTags() || canUpdateTags()"
:tag="tag"
@edit="showTagEditModal = true"
@delete="deleteTag"></TagMoreOptionsDropdown>
</div>
<TagEditModal v-model:show="showTagEditModal" :tag="tag"></TagEditModal>
</TableRow>
</template>
+1
View File
@@ -66,6 +66,7 @@ export type InviteMemberBody = ZodiosBodyByAlias<SolidTimeApi, 'invite'>;
export type MemberRole = InviteMemberBody['role'];
export type CreateTagBody = ZodiosBodyByAlias<SolidTimeApi, 'createTag'>;
export type UpdateTagBody = ZodiosBodyByAlias<SolidTimeApi, 'updateTag'>;
export type ImportType = ZodiosResponseByAlias<SolidTimeApi, 'getImporters'>['data'][0];
export type ImportReport = ZodiosResponseByAlias<SolidTimeApi, 'importData'>;
+4
View File
@@ -101,6 +101,10 @@ export function canCreateTags() {
return currentUserHasPermission('tags:create');
}
export function canUpdateTags() {
return currentUserHasPermission('tags:update');
}
export function canDeleteTags() {
return currentUserHasPermission('tags:delete');
}
+25 -3
View File
@@ -1,9 +1,9 @@
import { defineStore } from 'pinia';
import type { Tag } from '@/packages/api/src';
import type { Tag, UpdateTagBody } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src';
import { useNotificationsStore } from '@/utils/notification';
import { useQueryClient } from '@tanstack/vue-query';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
export const useTagsStore = defineStore('tags', () => {
const { handleApiRequestNotifications } = useNotificationsStore();
@@ -54,5 +54,27 @@ export const useTagsStore = defineStore('tags', () => {
}
}
return { createTag, deleteTag };
const { mutateAsync: updateTag } = useMutation({
mutationFn: async ({ tagId, tagBody }: { tagId: string; tagBody: UpdateTagBody }) => {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
return await handleApiRequestNotifications(
() =>
api.updateTag(tagBody, {
params: {
organization: organizationId,
tag: tagId,
},
}),
'Tag updated successfully',
'Failed to update tag'
);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
},
});
return { createTag, updateTag, deleteTag };
});