Skip to content

Commit 1721b28

Browse files
authored
Merge pull request #962 from Chris0Jeky/fix/fe-22-decompose-card-modal
FE-22: Decompose CardModal into sub-components and composable
2 parents 2bb73b9 + 5e16d54 commit 1721b28

10 files changed

Lines changed: 1884 additions & 605 deletions

File tree

frontend/taskdeck-web/src/components/board/CardModal.vue

Lines changed: 114 additions & 605 deletions
Large diffs are not rendered by default.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<script setup lang="ts">
2+
defineProps<{
3+
isFormValid: boolean
4+
}>()
5+
6+
defineEmits<{
7+
(e: 'save'): void
8+
(e: 'close'): void
9+
(e: 'delete-click'): void
10+
}>()
11+
</script>
12+
13+
<template>
14+
<div class="mt-6 flex items-center justify-between">
15+
<button
16+
@click="$emit('delete-click')"
17+
type="button"
18+
class="px-4 py-2 text-sm font-medium text-error hover:text-error/80 hover:bg-error/10 border border-error/40 rounded-md transition-colors"
19+
>
20+
Delete Card
21+
</button>
22+
<div class="flex gap-2">
23+
<button
24+
@click="$emit('close')"
25+
type="button"
26+
class="px-4 py-2 text-sm font-medium text-on-surface-variant hover:bg-surface-container-high border border-outline-variant/40 rounded-md transition-colors"
27+
>
28+
Cancel
29+
</button>
30+
<button
31+
@click="$emit('save')"
32+
:disabled="!isFormValid"
33+
type="button"
34+
class="px-4 py-2 text-sm font-medium text-on-primary-container bg-primary-container hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed rounded-md transition-all"
35+
>
36+
Save Changes
37+
</button>
38+
</div>
39+
</div>
40+
</template>
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<script setup lang="ts">
2+
import type { CardComment } from '../../../types/comments'
3+
4+
const props = defineProps<{
5+
topLevelComments: CardComment[]
6+
editingCommentId: string | null
7+
editingCommentContent: string
8+
replyDraftByParent: Record<string, string>
9+
canEditCommentFn: (comment: CardComment) => boolean
10+
getRepliesFn: (parentCommentId: string) => CardComment[]
11+
}>()
12+
13+
const emit = defineEmits<{
14+
(e: 'add-comment', parentCommentId?: string): void
15+
(e: 'start-edit-comment', comment: CardComment): void
16+
(e: 'cancel-edit-comment'): void
17+
(e: 'save-edit-comment', commentId: string): void
18+
(e: 'delete-comment', comment: CardComment): void
19+
(e: 'update:editingCommentContent', value: string): void
20+
(e: 'update:replyDraftByParent', value: Record<string, string>): void
21+
}>()
22+
23+
const newCommentContent = defineModel<string>('newCommentContent', { required: true })
24+
25+
function updateReplyDraft(commentId: string, value: string) {
26+
const updated = { ...props.replyDraftByParent, [commentId]: value }
27+
emit('update:replyDraftByParent', updated)
28+
}
29+
</script>
30+
31+
<template>
32+
<div class="pt-4 border-t border-outline-variant/30 space-y-3">
33+
<h3 class="text-sm font-semibold text-on-surface">Comments</h3>
34+
<div class="space-y-2">
35+
<textarea
36+
id="new-card-comment"
37+
v-model="newCommentContent"
38+
rows="2"
39+
class="w-full px-3 py-2 bg-surface-container-high border border-outline-variant/40 rounded-md text-on-surface placeholder-on-surface-variant/50 focus:outline-none focus:ring-2 focus:ring-primary/50"
40+
placeholder="Write a comment... Use @username to mention teammates."
41+
></textarea>
42+
<div class="flex justify-end">
43+
<button
44+
id="add-card-comment"
45+
type="button"
46+
class="px-3 py-1.5 text-sm font-medium text-on-primary-container bg-primary-container hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed rounded-md transition-all"
47+
:disabled="newCommentContent.trim().length === 0"
48+
@click="$emit('add-comment')"
49+
>
50+
Add Comment
51+
</button>
52+
</div>
53+
</div>
54+
55+
<div v-if="topLevelComments.length === 0" class="text-sm text-on-surface-variant italic">
56+
No comments yet.
57+
</div>
58+
59+
<div v-else class="space-y-3">
60+
<div
61+
v-for="comment in topLevelComments"
62+
:key="comment.id"
63+
class="border border-outline-variant/30 rounded-md p-3 space-y-2 bg-surface-container-low"
64+
>
65+
<div class="flex items-start justify-between gap-2">
66+
<div class="text-xs text-on-surface-variant">
67+
<span class="font-medium text-on-surface">{{ comment.authorUsername }}</span>
68+
<span class="mx-1">&bull;</span>
69+
<span>{{ new Date(comment.createdAt).toLocaleString() }}</span>
70+
<span v-if="comment.editedAt" class="ml-1 italic">(edited)</span>
71+
</div>
72+
<div v-if="canEditCommentFn(comment) && !comment.isDeleted" class="flex gap-2 text-xs">
73+
<button
74+
type="button"
75+
class="text-primary hover:text-primary/80"
76+
@click="$emit('start-edit-comment', comment)"
77+
>
78+
Edit
79+
</button>
80+
<button
81+
type="button"
82+
class="text-error hover:text-error/80"
83+
@click="$emit('delete-comment', comment)"
84+
>
85+
Delete
86+
</button>
87+
</div>
88+
</div>
89+
90+
<div v-if="editingCommentId === comment.id" class="space-y-2">
91+
<textarea
92+
:value="editingCommentContent"
93+
aria-label="Edit comment"
94+
rows="2"
95+
class="w-full px-3 py-2 bg-surface-container-high border border-outline-variant/40 rounded-md text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50"
96+
@input="$emit('update:editingCommentContent', ($event.target as HTMLTextAreaElement).value)"
97+
></textarea>
98+
<div class="flex justify-end gap-2">
99+
<button
100+
type="button"
101+
class="px-3 py-1.5 text-sm text-on-surface-variant border border-outline-variant/40 rounded-md hover:bg-surface-container-high transition-colors"
102+
@click="$emit('cancel-edit-comment')"
103+
>
104+
Cancel
105+
</button>
106+
<button
107+
type="button"
108+
class="px-3 py-1.5 text-sm text-on-primary-container bg-primary-container rounded-md hover:brightness-110 disabled:opacity-40"
109+
:disabled="editingCommentContent.trim().length === 0"
110+
@click="$emit('save-edit-comment', comment.id)"
111+
>
112+
Save
113+
</button>
114+
</div>
115+
</div>
116+
117+
<p
118+
v-else
119+
class="text-sm whitespace-pre-wrap"
120+
:class="comment.isDeleted ? 'text-on-surface-variant italic' : 'text-on-surface'"
121+
>
122+
{{ comment.content }}
123+
</p>
124+
125+
<div class="pl-3 border-l-2 border-outline-variant/30 space-y-2">
126+
<div
127+
v-for="reply in getRepliesFn(comment.id)"
128+
:key="reply.id"
129+
class="space-y-1"
130+
>
131+
<div class="text-xs text-on-surface-variant">
132+
<span class="font-medium text-on-surface">{{ reply.authorUsername }}</span>
133+
<span class="mx-1">&bull;</span>
134+
<span>{{ new Date(reply.createdAt).toLocaleString() }}</span>
135+
</div>
136+
<p
137+
class="text-sm whitespace-pre-wrap"
138+
:class="reply.isDeleted ? 'text-on-surface-variant italic' : 'text-on-surface'"
139+
>
140+
{{ reply.content }}
141+
</p>
142+
</div>
143+
144+
<div v-if="!comment.isDeleted" class="space-y-2 pt-1">
145+
<textarea
146+
:value="replyDraftByParent[comment.id] ?? ''"
147+
aria-label="Reply to comment"
148+
rows="2"
149+
class="w-full px-3 py-2 bg-surface-container-high border border-outline-variant/40 rounded-md text-on-surface placeholder-on-surface-variant/50 focus:outline-none focus:ring-2 focus:ring-primary/50"
150+
placeholder="Reply..."
151+
@input="updateReplyDraft(comment.id, ($event.target as HTMLTextAreaElement).value)"
152+
></textarea>
153+
<div class="flex justify-end">
154+
<button
155+
type="button"
156+
class="px-3 py-1.5 text-sm font-medium text-on-primary-container bg-primary-container hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed rounded-md transition-all"
157+
:disabled="!(replyDraftByParent[comment.id] ?? '').trim().length"
158+
@click="$emit('add-comment', comment.id)"
159+
>
160+
Reply
161+
</button>
162+
</div>
163+
</div>
164+
</div>
165+
</div>
166+
</div>
167+
</div>
168+
</template>
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<script setup lang="ts">
2+
import type { Card } from '../../../types/board'
3+
4+
defineProps<{
5+
card: Card
6+
formattedDueDate: string
7+
isOverdue: boolean
8+
}>()
9+
10+
const title = defineModel<string>('title', { required: true })
11+
const description = defineModel<string>('description', { required: true })
12+
const dueDate = defineModel<string>('dueDate', { required: true })
13+
const isBlocked = defineModel<boolean>('isBlocked', { required: true })
14+
const blockReason = defineModel<string>('blockReason', { required: true })
15+
16+
defineEmits<{
17+
(e: 'clear-due-date'): void
18+
}>()
19+
</script>
20+
21+
<template>
22+
<!-- Title -->
23+
<div>
24+
<label for="card-title" class="block text-sm font-medium text-on-surface-variant mb-1">
25+
Title *
26+
</label>
27+
<input
28+
id="card-title"
29+
v-model="title"
30+
type="text"
31+
required
32+
class="w-full px-3 py-2 bg-surface-container-high border border-outline-variant/40 rounded-md text-on-surface placeholder-on-surface-variant/50 focus:outline-none focus:ring-2 focus:ring-primary/50"
33+
placeholder="Card title"
34+
/>
35+
</div>
36+
37+
<!-- Description -->
38+
<div>
39+
<label for="card-description" class="block text-sm font-medium text-on-surface-variant mb-1">
40+
Description
41+
</label>
42+
<textarea
43+
id="card-description"
44+
v-model="description"
45+
rows="4"
46+
class="w-full px-3 py-2 bg-surface-container-high border border-outline-variant/40 rounded-md text-on-surface placeholder-on-surface-variant/50 focus:outline-none focus:ring-2 focus:ring-primary/50"
47+
placeholder="Add a more detailed description..."
48+
></textarea>
49+
</div>
50+
51+
<!-- Due Date -->
52+
<div>
53+
<label for="card-due-date" class="block text-sm font-medium text-on-surface-variant mb-1">
54+
Due Date
55+
</label>
56+
<div class="flex gap-2">
57+
<input
58+
id="card-due-date"
59+
v-model="dueDate"
60+
type="date"
61+
class="flex-1 px-3 py-2 bg-surface-container-high border border-outline-variant/40 rounded-md text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50"
62+
/>
63+
<button
64+
v-if="dueDate"
65+
@click="$emit('clear-due-date')"
66+
type="button"
67+
class="px-3 py-2 text-sm text-on-surface-variant hover:text-on-surface border border-outline-variant/40 rounded-md hover:bg-surface-container-high transition-colors"
68+
>
69+
Clear
70+
</button>
71+
</div>
72+
<p v-if="card.dueDate" class="mt-1 text-xs" :class="isOverdue ? 'text-error' : 'text-on-surface-variant'">
73+
Current: {{ formattedDueDate }}
74+
<span v-if="isOverdue" class="font-medium">(Overdue)</span>
75+
</p>
76+
</div>
77+
78+
<!-- Blocked Status -->
79+
<div class="border border-outline-variant/30 rounded-md p-4">
80+
<div class="flex items-center mb-2">
81+
<input
82+
id="card-is-blocked"
83+
v-model="isBlocked"
84+
type="checkbox"
85+
class="w-4 h-4 text-primary border-outline-variant rounded focus:ring-primary/50"
86+
/>
87+
<label for="card-is-blocked" class="ml-2 text-sm font-medium text-on-surface-variant">
88+
Mark as blocked
89+
</label>
90+
</div>
91+
<div v-if="isBlocked">
92+
<label for="card-block-reason" class="block text-sm font-medium text-on-surface-variant mb-1">
93+
Block Reason *
94+
</label>
95+
<textarea
96+
id="card-block-reason"
97+
v-model="blockReason"
98+
rows="2"
99+
required
100+
class="w-full px-3 py-2 bg-surface-container-high border border-outline-variant/40 rounded-md text-on-surface placeholder-on-surface-variant/50 focus:outline-none focus:ring-2 focus:ring-primary/50"
101+
placeholder="Why is this card blocked?"
102+
></textarea>
103+
</div>
104+
</div>
105+
</template>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script setup lang="ts">
2+
defineEmits<{
3+
(e: 'close'): void
4+
}>()
5+
</script>
6+
7+
<template>
8+
<div class="flex items-start justify-between mb-4">
9+
<h2 class="text-2xl font-semibold text-on-surface">Edit Card</h2>
10+
<button
11+
@click="$emit('close')"
12+
class="text-on-surface-variant hover:text-on-surface transition-colors"
13+
>
14+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
15+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
16+
</svg>
17+
</button>
18+
</div>
19+
</template>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<script setup lang="ts">
2+
import type { Label } from '../../../types/board'
3+
4+
defineProps<{
5+
labels: Label[]
6+
}>()
7+
8+
const selectedLabelIds = defineModel<string[]>('selectedLabelIds', { required: true })
9+
</script>
10+
11+
<template>
12+
<div>
13+
<p class="block text-sm font-medium text-on-surface-variant mb-2">
14+
Labels
15+
</p>
16+
<div v-if="labels.length > 0" class="flex flex-col gap-2">
17+
<label
18+
v-for="label in labels"
19+
:key="label.id"
20+
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all cursor-pointer"
21+
:class="selectedLabelIds.includes(label.id)
22+
? 'text-white ring-2 ring-offset-2 ring-primary/50'
23+
: 'text-on-surface bg-surface-container-high hover:bg-surface-container-highest'"
24+
:style="selectedLabelIds.includes(label.id) ? { backgroundColor: label.colorHex } : {}"
25+
>
26+
<input
27+
:id="`label-${label.id}`"
28+
v-model="selectedLabelIds"
29+
type="checkbox"
30+
:value="label.id"
31+
class="w-4 h-4 text-primary border-outline-variant rounded focus:ring-primary/50"
32+
/>
33+
<!-- Color swatch always visible so users can identify labels before selecting -->
34+
<span
35+
class="inline-block w-3 h-3 rounded-full flex-shrink-0 bg-outline-variant"
36+
:style="label.colorHex ? { backgroundColor: label.colorHex } : {}"
37+
aria-hidden="true"
38+
/>
39+
<span>{{ label.name }}</span>
40+
</label>
41+
</div>
42+
<p v-else class="text-sm text-on-surface-variant italic">No labels available</p>
43+
</div>
44+
</template>

0 commit comments

Comments
 (0)