Skip to content

Commit 2c4af95

Browse files
committed
Add Tag Edit Modal and UI
1 parent 2159571 commit 2c4af95

File tree

7 files changed

+158
-6
lines changed

7 files changed

+158
-6
lines changed

e2e/tags.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,35 @@ test('test that multiple tags can be created via API and displayed in the table'
8888
await expect(page.getByTestId('tag_table')).toContainText(tagName1);
8989
await expect(page.getByTestId('tag_table')).toContainText(tagName2);
9090
});
91+
92+
// =============================================
93+
// Employee Permission Tests
94+
// =============================================
95+
96+
test.describe('Employee Tags Restrictions', () => {
97+
test('employee can view tags but cannot create', async ({ ctx, employee }) => {
98+
const tagName = 'EmpViewTag ' + Math.floor(Math.random() * 10000);
99+
await createTagViaApi(ctx, { name: tagName });
100+
101+
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/tags');
102+
await expect(employee.page.getByTestId('tags_view')).toBeVisible({ timeout: 10000 });
103+
104+
// Employee can see the tag (tags are visible to all members with tags:view)
105+
await expect(employee.page.getByText(tagName)).toBeVisible({ timeout: 10000 });
106+
107+
// Employee cannot see Create Tag button
108+
await expect(employee.page.getByRole('button', { name: 'Create Tag' })).not.toBeVisible();
109+
});
110+
111+
test('employee cannot see edit/delete actions on tags', async ({ ctx, employee }) => {
112+
const tagName = 'EmpActionsTag ' + Math.floor(Math.random() * 10000);
113+
await createTagViaApi(ctx, { name: tagName });
114+
115+
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/tags');
116+
await expect(employee.page.getByText(tagName)).toBeVisible({ timeout: 10000 });
117+
118+
// Actions button should not be visible for employee
119+
const actionsButton = employee.page.locator(`[aria-label='Actions for Tag ${tagName}']`);
120+
await expect(actionsButton).not.toBeVisible();
121+
});
122+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<script setup lang="ts">
2+
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
3+
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
4+
import DialogModal from '@/packages/ui/src/DialogModal.vue';
5+
import { ref } from 'vue';
6+
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
7+
import { useFocus } from '@vueuse/core';
8+
import { useTagsStore } from '@/utils/useTags';
9+
import type { Tag, UpdateTagBody } from '@/packages/api/src';
10+
11+
const { updateTag } = useTagsStore();
12+
const show = defineModel('show', { default: false });
13+
const saving = ref(false);
14+
15+
const props = defineProps<{
16+
tag: Tag;
17+
}>();
18+
19+
const tagBody = ref<UpdateTagBody>({
20+
name: props.tag.name,
21+
});
22+
23+
async function submit() {
24+
saving.value = true;
25+
try {
26+
await updateTag({ tagId: props.tag.id, tagBody: tagBody.value });
27+
show.value = false;
28+
} finally {
29+
saving.value = false;
30+
}
31+
}
32+
33+
const tagNameInput = ref<HTMLInputElement | null>(null);
34+
35+
useFocus(tagNameInput, { initialValue: true });
36+
</script>
37+
38+
<template>
39+
<DialogModal closeable :show="show" @close="show = false">
40+
<template #title>
41+
<div class="flex space-x-2">
42+
<span> Update Tag </span>
43+
</div>
44+
</template>
45+
46+
<template #content>
47+
<div class="flex items-center space-x-4">
48+
<div class="col-span-6 sm:col-span-4 flex-1">
49+
<TextInput
50+
id="tagName"
51+
ref="tagNameInput"
52+
v-model="tagBody.name"
53+
type="text"
54+
placeholder="Tag Name"
55+
class="mt-1 block w-full"
56+
required
57+
autocomplete="tagName"
58+
@keydown.enter="submit()" />
59+
</div>
60+
</div>
61+
</template>
62+
<template #footer>
63+
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
64+
<PrimaryButton
65+
class="ms-3"
66+
:class="{ 'opacity-25': saving }"
67+
:disabled="saving"
68+
@click="submit">
69+
Update Tag
70+
</PrimaryButton>
71+
</template>
72+
</DialogModal>
73+
</template>
74+
75+
<style scoped></style>

resources/js/Components/Common/Tag/TagMoreOptionsDropdown.vue

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
2-
import { TrashIcon } from '@heroicons/vue/20/solid';
2+
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
3+
import { canDeleteTags, canUpdateTags } from '@/utils/permissions';
34
import type { Tag } from '@/packages/api/src';
45
import {
56
DropdownMenu,
@@ -9,6 +10,7 @@ import {
910
} from '@/Components/ui/dropdown-menu';
1011
1112
const emit = defineEmits<{
13+
edit: [];
1214
delete: [];
1315
}>();
1416
const props = defineProps<{
@@ -38,6 +40,16 @@ const props = defineProps<{
3840
</DropdownMenuTrigger>
3941
<DropdownMenuContent class="min-w-[150px]" align="end">
4042
<DropdownMenuItem
43+
v-if="canUpdateTags()"
44+
:aria-label="'Edit Tag ' + props.tag.name"
45+
data-testid="tag_edit"
46+
class="flex items-center space-x-3 cursor-pointer"
47+
@click="emit('edit')">
48+
<PencilSquareIcon class="w-5 text-icon-active" />
49+
<span>Edit</span>
50+
</DropdownMenuItem>
51+
<DropdownMenuItem
52+
v-if="canDeleteTags()"
4153
:aria-label="'Delete Tag ' + props.tag.name"
4254
data-testid="tag_delete"
4355
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"

resources/js/Components/Common/Tag/TagTableRow.vue

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22
import type { Tag } from '@/packages/api/src';
33
import { useTagsStore } from '@/utils/useTags';
44
import TagMoreOptionsDropdown from '@/Components/Common/Tag/TagMoreOptionsDropdown.vue';
5+
import TagEditModal from '@/Components/Common/Tag/TagEditModal.vue';
56
import TableRow from '@/Components/TableRow.vue';
6-
import { canDeleteTags } from '@/utils/permissions';
7+
import { canDeleteTags, canUpdateTags } from '@/utils/permissions';
8+
import { ref } from 'vue';
79
810
const props = defineProps<{
911
tag: Tag;
1012
}>();
1113
14+
const showTagEditModal = ref(false);
15+
1216
function deleteTag() {
1317
useTagsStore().deleteTag(props.tag.id);
1418
}
@@ -25,10 +29,12 @@ function deleteTag() {
2529
<div
2630
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">
2731
<TagMoreOptionsDropdown
28-
v-if="canDeleteTags()"
32+
v-if="canDeleteTags() || canUpdateTags()"
2933
:tag="tag"
34+
@edit="showTagEditModal = true"
3035
@delete="deleteTag"></TagMoreOptionsDropdown>
3136
</div>
37+
<TagEditModal v-model:show="showTagEditModal" :tag="tag"></TagEditModal>
3238
</TableRow>
3339
</template>
3440

resources/js/packages/api/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export type InviteMemberBody = ZodiosBodyByAlias<SolidTimeApi, 'invite'>;
6666
export type MemberRole = InviteMemberBody['role'];
6767

6868
export type CreateTagBody = ZodiosBodyByAlias<SolidTimeApi, 'createTag'>;
69+
export type UpdateTagBody = ZodiosBodyByAlias<SolidTimeApi, 'updateTag'>;
6970

7071
export type ImportType = ZodiosResponseByAlias<SolidTimeApi, 'getImporters'>['data'][0];
7172
export type ImportReport = ZodiosResponseByAlias<SolidTimeApi, 'importData'>;

resources/js/utils/permissions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ export function canCreateTags() {
101101
return currentUserHasPermission('tags:create');
102102
}
103103

104+
export function canUpdateTags() {
105+
return currentUserHasPermission('tags:update');
106+
}
107+
104108
export function canDeleteTags() {
105109
return currentUserHasPermission('tags:delete');
106110
}

resources/js/utils/useTags.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { defineStore } from 'pinia';
2-
import type { Tag } from '@/packages/api/src';
2+
import type { Tag, UpdateTagBody } from '@/packages/api/src';
33
import { getCurrentOrganizationId } from '@/utils/useUser';
44
import { api } from '@/packages/api/src';
55
import { useNotificationsStore } from '@/utils/notification';
6-
import { useQueryClient } from '@tanstack/vue-query';
6+
import { useMutation, useQueryClient } from '@tanstack/vue-query';
77

88
export const useTagsStore = defineStore('tags', () => {
99
const { handleApiRequestNotifications } = useNotificationsStore();
@@ -54,5 +54,27 @@ export const useTagsStore = defineStore('tags', () => {
5454
}
5555
}
5656

57-
return { createTag, deleteTag };
57+
const { mutateAsync: updateTag } = useMutation({
58+
mutationFn: async ({ tagId, tagBody }: { tagId: string; tagBody: UpdateTagBody }) => {
59+
const organizationId = getCurrentOrganizationId();
60+
if (organizationId) {
61+
return await handleApiRequestNotifications(
62+
() =>
63+
api.updateTag(tagBody, {
64+
params: {
65+
organization: organizationId,
66+
tag: tagId,
67+
},
68+
}),
69+
'Tag updated successfully',
70+
'Failed to update tag'
71+
);
72+
}
73+
},
74+
onSuccess: () => {
75+
queryClient.invalidateQueries({ queryKey: ['tags'] });
76+
},
77+
});
78+
79+
return { createTag, updateTag, deleteTag };
5880
});

0 commit comments

Comments
 (0)