Merge branch 'main' of github.com:jcalixte/loopycode
This commit is contained in:
@@ -23,13 +23,13 @@ const getNextStepId = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!recordStore.currentStepId) {
|
||||
if (!record.value.currentStepId) {
|
||||
const [firstStep] = task.value.steps
|
||||
return firstStep.id
|
||||
}
|
||||
|
||||
const currentStepIndex = task.value.steps.findIndex(
|
||||
(step) => step.id === recordStore.currentStepId
|
||||
(step) => step.id === record.value.currentStepId
|
||||
)
|
||||
|
||||
const canHaveNextIndex =
|
||||
@@ -42,10 +42,12 @@ const getNextStepId = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasStarted = computed(
|
||||
() => Object.values(record.value?.stepRecords ?? {}).length > 0
|
||||
)
|
||||
|
||||
const canStart = computed(
|
||||
() =>
|
||||
!recordStore.currentStepId &&
|
||||
Object.values(record.value?.stepRecords ?? {}).length === 0
|
||||
() => !record.value.currentStepId && !hasStarted.value
|
||||
)
|
||||
|
||||
const startRecording = () => {
|
||||
@@ -61,13 +63,13 @@ const startRecording = () => {
|
||||
}
|
||||
|
||||
const nextStep = () => {
|
||||
if (!task.value || !recordStore.currentStepId || !record.value) {
|
||||
if (!task.value || !record.value.currentStepId || !record.value) {
|
||||
return
|
||||
}
|
||||
|
||||
recordStore.nextStepRecord({
|
||||
taskId: record.value.taskId,
|
||||
currentStepId: recordStore.currentStepId,
|
||||
currentStepId: record.value.currentStepId,
|
||||
nextStepId: getNextStepId(),
|
||||
tick: toISODate(new Date())
|
||||
})
|
||||
@@ -107,48 +109,54 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="record-controls buttons has-addons">
|
||||
<template v-if="record && recordStore.currentStepId">
|
||||
<button
|
||||
class="button is-primary is-light"
|
||||
v-if="record.breakTime"
|
||||
@click="recordStore.resume(taskId)"
|
||||
>
|
||||
<img src="/icons/start.svg" alt="resume" />
|
||||
</button>
|
||||
<button
|
||||
class="button is-primary is-light"
|
||||
v-else
|
||||
@click="recordStore.pause(taskId)"
|
||||
>
|
||||
<img src="/icons/pause.svg" alt="pause" />
|
||||
</button>
|
||||
</template>
|
||||
<div class="columns record-controls">
|
||||
<div class="column buttons has-addons">
|
||||
<template v-if="record && record.currentStepId">
|
||||
<button
|
||||
class="button is-primary is-light"
|
||||
v-if="record.breakTime"
|
||||
@click="recordStore.resume(taskId)"
|
||||
>
|
||||
<img src="/icons/start.svg" alt="resume" />
|
||||
</button>
|
||||
<button
|
||||
class="button is-primary is-light"
|
||||
v-else
|
||||
@click="recordStore.pause(taskId)"
|
||||
>
|
||||
<img src="/icons/pause.svg" alt="pause" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-if="!record || !record.end">
|
||||
<button
|
||||
v-if="canStart"
|
||||
@click="startRecording"
|
||||
class="button is-primary is-light"
|
||||
>
|
||||
<img src="/icons/start.svg" alt="start" />
|
||||
</button>
|
||||
<button
|
||||
class="button is-primary is-light"
|
||||
v-else-if="!record?.breakTime"
|
||||
@click="nextStep"
|
||||
>
|
||||
<img src="/icons/next.svg" alt="next" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="!record || !record.end">
|
||||
<button
|
||||
v-if="canStart"
|
||||
@click="startRecording"
|
||||
class="button is-primary is-light"
|
||||
>
|
||||
<img src="/icons/start.svg" alt="start" />
|
||||
</button>
|
||||
<button
|
||||
class="button is-primary is-light"
|
||||
v-else-if="!record?.breakTime"
|
||||
@click="nextStep"
|
||||
>
|
||||
<img src="/icons/next.svg" alt="next" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button class="button is-warning" @click="recordStore.reset(taskId)">
|
||||
<img src="/icons/recycle.svg" alt="reset" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="message">
|
||||
<p><kbd>s</kbd>: start record</p>
|
||||
<p><kbd>n</kbd>: next step</p>
|
||||
<p><kbd>p</kbd>: pause</p>
|
||||
<button
|
||||
v-if="hasStarted"
|
||||
class="button is-warning"
|
||||
@click="recordStore.reset(taskId)"
|
||||
>
|
||||
<img src="/icons/recycle.svg" alt="reset" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="column message">
|
||||
<p><kbd>s</kbd>: start record</p>
|
||||
<p><kbd>n</kbd>: next step</p>
|
||||
<p><kbd>p</kbd>: pause</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
39
src/modules/record/components/RecordResume.vue
Normal file
39
src/modules/record/components/RecordResume.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { useTaskRecordMetadata } from '@/modules/record/hooks/useTaskRecordMetadata'
|
||||
import type { Recordable } from '@/modules/record/interfaces/recordable'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
record: Recordable
|
||||
totalEstimation: number
|
||||
}>()
|
||||
|
||||
const { duration } = useTaskRecordMetadata(props.record)
|
||||
|
||||
const isSuperiorToEstimation = computed(() => {
|
||||
if (!duration.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return duration.value > props.totalEstimation
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="record-resume content" v-if="record.end">
|
||||
<p
|
||||
:class="{
|
||||
'has-text-primary-dark': !isSuperiorToEstimation,
|
||||
'has-text-warning-dark': isSuperiorToEstimation
|
||||
}"
|
||||
>
|
||||
The task took {{ duration }} minutes instead of
|
||||
{{ totalEstimation }} minutes.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.record-resume {
|
||||
}
|
||||
</style>
|
||||
@@ -19,7 +19,9 @@ const step = computed(() => taskStore.getStep(props.taskId, props.stepId))
|
||||
const stepRecord = computed(() =>
|
||||
recordStore.getStepRecord(props.taskId, props.stepId)
|
||||
)
|
||||
const isCurrentStep = computed(() => recordStore.currentStepId === props.stepId)
|
||||
const isCurrentStep = computed(
|
||||
() => record.value?.currentStepId === props.stepId
|
||||
)
|
||||
const isInBreakTime = computed(() => !!record.value?.breakTime)
|
||||
|
||||
const now = ref(toISODate(new Date()))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import EstimationTimeArrival from '@/components/EstimationTimeArrival.vue'
|
||||
import RecordResume from '@/modules/record/components/RecordResume.vue'
|
||||
import { useTaskStore } from '@/modules/task/stores/useTask.store'
|
||||
import { formatLongDate } from '@/shared/format-date'
|
||||
import { useLoopyTitle } from '@/shared/useLoopyTitle'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTaskRecordMetadata } from '../hooks/useTaskRecordMetadata'
|
||||
import { useTaskRecordStore } from '../stores/useTaskRecordStore'
|
||||
import RecordControls from './RecordControls.vue'
|
||||
import RecordProgress from './RecordProgress.vue'
|
||||
@@ -19,23 +19,17 @@ const taskStore = useTaskStore()
|
||||
const recordStore = useTaskRecordStore()
|
||||
const router = useRouter()
|
||||
|
||||
recordStore.addRecord(props.taskId)
|
||||
|
||||
const task = computed(() => taskStore.getTask(props.taskId))
|
||||
|
||||
if (task.value) {
|
||||
recordStore.syncTaskRecord(task.value)
|
||||
}
|
||||
recordStore.addRecord(props.taskId)
|
||||
|
||||
useLoopyTitle(task.value?.title ?? '')
|
||||
|
||||
const record = computed(() => recordStore.getTaskRecord(props.taskId))
|
||||
const recordNotes = computed(() => recordStore.getRecordNotes(props.taskId))
|
||||
const { duration } = useTaskRecordMetadata(record)
|
||||
|
||||
const isSuperiorToEstimation = computed(() => {
|
||||
if (!task.value || !record.value || !duration.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return duration.value > task.value.totalEstimation
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -72,17 +66,11 @@ const isSuperiorToEstimation = computed(() => {
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-show="record && record.end" class="content">
|
||||
<p
|
||||
:class="{
|
||||
'has-text-primary-dark': !isSuperiorToEstimation,
|
||||
'has-text-warning-dark': isSuperiorToEstimation
|
||||
}"
|
||||
>
|
||||
The task took {{ duration }} minutes instead of
|
||||
{{ task.totalEstimation }} minutes.
|
||||
</p>
|
||||
</div>
|
||||
<record-resume
|
||||
v-if="record"
|
||||
:record="record"
|
||||
:total-estimation="task.totalEstimation"
|
||||
/>
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-half">
|
||||
<textarea
|
||||
|
||||
@@ -27,7 +27,6 @@ const mountTaskRecordPreview = (withRecord = false) => {
|
||||
'task-store': { tasks: [task] },
|
||||
'task-record-store': withRecord
|
||||
? {
|
||||
currentStepId: null,
|
||||
records: {
|
||||
[task.id]: record
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Recordable } from '@/modules/record/interfaces/recordable'
|
||||
import { formatDiffInMinutes } from '@/shared/format-date'
|
||||
import { computed, isRef, type Ref } from 'vue'
|
||||
import type { TaskRecord } from '../models/task-record'
|
||||
|
||||
export const useTaskRecordMetadata = (
|
||||
record: TaskRecord | Ref<TaskRecord | null>
|
||||
record: Recordable | Ref<Recordable | null>
|
||||
) => {
|
||||
const taskDurations = computed(() => {
|
||||
const recordValue = isRef(record) ? record.value : record
|
||||
|
||||
@@ -14,5 +14,6 @@ export const fixtureRecordable = (
|
||||
start: toISODate(faker.datatype.datetime())
|
||||
}
|
||||
},
|
||||
currentStepId: null,
|
||||
end: partialRecordable?.end
|
||||
})
|
||||
|
||||
@@ -8,4 +8,5 @@ export interface Recordable {
|
||||
stepRecords: Record<string, TimeRange>
|
||||
notes: string
|
||||
breakTime?: TimeRange
|
||||
currentStepId: string | null
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ import { toISODate } from '@/shared/types/date'
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Recordable } from '../interfaces/recordable'
|
||||
import { fixtureRecordable } from '../interfaces/recordable.fixture'
|
||||
import { fixtureTimeRange } from '../interfaces/time-range.fixture'
|
||||
import { TaskRecord } from './task-record'
|
||||
|
||||
describe('Task Record', () => {
|
||||
it('creates a Record from a Recordable', () => {
|
||||
const recordable: Recordable = {
|
||||
const recordable: Recordable = fixtureRecordable({
|
||||
taskId: faker.datatype.uuid(),
|
||||
notes: faker.lorem.paragraphs(),
|
||||
start: toISODate(faker.date.past(1)),
|
||||
@@ -16,7 +17,7 @@ describe('Task Record', () => {
|
||||
stepRecords: {
|
||||
[faker.datatype.uuid()]: fixtureTimeRange()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(TaskRecord.fromRecordable(recordable)).toEqual(recordable)
|
||||
})
|
||||
|
||||
@@ -8,17 +8,15 @@ export class TaskRecord implements Recordable {
|
||||
public stepRecords: Record<string, TimeRange> = {}
|
||||
public notes = ''
|
||||
public breakTime?: TimeRange
|
||||
public currentStepId: string | null = null
|
||||
|
||||
public constructor(public readonly taskId: string) {}
|
||||
|
||||
public get hasStepRecords() {
|
||||
return Object.values(this.stepRecords).length > 0
|
||||
}
|
||||
|
||||
public static fromRecordable(recordable: Recordable) {
|
||||
const taskRecord = new TaskRecord(recordable.taskId)
|
||||
|
||||
taskRecord.stepRecords = recordable.stepRecords
|
||||
taskRecord.currentStepId = recordable.currentStepId
|
||||
taskRecord.start = recordable.start
|
||||
taskRecord.end = recordable.end
|
||||
taskRecord.breakTime = recordable.breakTime
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Task } from '@/modules/task/models/task'
|
||||
import { toISODate, type ISODate } from '@/shared/types/date'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { Recordable } from '../interfaces/recordable'
|
||||
@@ -6,17 +7,35 @@ import { TaskRecord } from '../models/task-record'
|
||||
import { addBreakTimeToStepRecords } from '../services/breaktime-service'
|
||||
|
||||
export interface TaskRecordStoreState {
|
||||
currentStepId: string | null
|
||||
records: { [recordId: string]: Recordable }
|
||||
}
|
||||
|
||||
export const useTaskRecordStore = defineStore('task-record-store', {
|
||||
persist: true,
|
||||
state: (): TaskRecordStoreState => ({
|
||||
currentStepId: null,
|
||||
records: {}
|
||||
}),
|
||||
actions: {
|
||||
syncTaskRecord(task: Task) {
|
||||
if (!(task.id in this.records)) {
|
||||
return
|
||||
}
|
||||
|
||||
const record = this.records[task.id]
|
||||
|
||||
const taskRecordStepIds = Object.keys(record)
|
||||
const taskStepIds = new Set(task.steps.map((step) => step.id))
|
||||
|
||||
const hasSameSteps =
|
||||
taskRecordStepIds.length === taskStepIds.size &&
|
||||
taskRecordStepIds.every((taskRecordStepId) =>
|
||||
taskStepIds.has(taskRecordStepId)
|
||||
)
|
||||
|
||||
if (!hasSameSteps) {
|
||||
this.records[task.id] = new TaskRecord(task.id)
|
||||
}
|
||||
},
|
||||
addRecord(taskId: string) {
|
||||
if (taskId in this.records) {
|
||||
return
|
||||
@@ -57,10 +76,10 @@ export const useTaskRecordStore = defineStore('task-record-store', {
|
||||
[params.stepId]: {
|
||||
start: params.start
|
||||
}
|
||||
}
|
||||
},
|
||||
currentStepId: params.stepId
|
||||
}
|
||||
},
|
||||
currentStepId: params.stepId
|
||||
}
|
||||
})
|
||||
},
|
||||
endStepRecord(params: { taskId: string; stepId: string; end: ISODate }) {
|
||||
@@ -101,30 +120,32 @@ export const useTaskRecordStore = defineStore('task-record-store', {
|
||||
}
|
||||
|
||||
this.records[taskId].end = toISODate(new Date())
|
||||
this.currentStepId = null
|
||||
this.records[taskId].currentStepId = null
|
||||
},
|
||||
updateRecordNotes(taskId: string, notes: string) {
|
||||
const record = this.records[taskId]
|
||||
|
||||
if (record) {
|
||||
this.$patch({
|
||||
records: {
|
||||
...this.records,
|
||||
[taskId]: {
|
||||
...record,
|
||||
notes
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$patch({
|
||||
records: {
|
||||
...this.records,
|
||||
[taskId]: {
|
||||
...record,
|
||||
notes
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
reset(taskId: string) {
|
||||
this.currentStepId = null
|
||||
if (!this.records[taskId]) {
|
||||
return
|
||||
}
|
||||
this.records[taskId].stepRecords = {}
|
||||
this.records[taskId].end = undefined
|
||||
this.records[taskId].currentStepId = null
|
||||
},
|
||||
pause(taskId: string) {
|
||||
if (this.records[taskId]?.breakTime) {
|
||||
|
||||
@@ -1,79 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import EstimationTimeArrival from '@/components/EstimationTimeArrival.vue'
|
||||
import { createUuid } from '@/shared/create-uuid'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Stepable } from '../interfaces/stepable'
|
||||
import { Task } from '../models/task'
|
||||
import { useTaskStore } from '../stores/useTask.store'
|
||||
import StepInput from './StepInput.vue'
|
||||
|
||||
const store = useTaskStore()
|
||||
const router = useRouter()
|
||||
import TaskForm from './TaskForm.vue'
|
||||
|
||||
const id = createUuid()
|
||||
|
||||
const title = ref('')
|
||||
const link = ref('')
|
||||
|
||||
const steps = ref<Stepable[]>([])
|
||||
|
||||
const totalEstimation = computed(() =>
|
||||
steps.value.map((step) => step.estimation).reduce((a, b) => a + b, 0)
|
||||
)
|
||||
|
||||
const saveTask = () => {
|
||||
const task = new Task(id, title.value)
|
||||
if (link.value) {
|
||||
task.link = link.value
|
||||
}
|
||||
task.addSteps(...steps.value)
|
||||
|
||||
if (Task.validate(task)) {
|
||||
store.saveTask(task)
|
||||
router.push({
|
||||
name: 'task-view',
|
||||
params: {
|
||||
id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-half">
|
||||
<h1 class="title">Create a task</h1>
|
||||
<h2 class="subtitle">
|
||||
<estimation-time-arrival :estimation="totalEstimation" />
|
||||
</h2>
|
||||
<form @submit.prevent="saveTask">
|
||||
<div class="field">
|
||||
<label class="label" for="title">Title</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" id="title" v-model="title" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="link">User story link</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" id="link" v-model="link" />
|
||||
</div>
|
||||
</div>
|
||||
<step-input v-model="steps" />
|
||||
<button class="button is-primary is-fullwidth" type="submit">
|
||||
save
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<task-form :id="id" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
form {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
85
src/modules/task/components/TaskForm.vue
Normal file
85
src/modules/task/components/TaskForm.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import EstimationTimeArrival from '@/components/EstimationTimeArrival.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Stepable } from '../interfaces/stepable'
|
||||
import type { Taskable } from '../interfaces/taskable'
|
||||
import { Task } from '../models/task'
|
||||
import { useTaskStore } from '../stores/useTask.store'
|
||||
import StepInput from './StepInput.vue'
|
||||
|
||||
const store = useTaskStore()
|
||||
const router = useRouter()
|
||||
const props = defineProps<{ id: string; initialTask?: Taskable }>()
|
||||
const id = computed(() => props.id)
|
||||
|
||||
const steps = ref<Stepable[]>(props.initialTask?.steps ?? [])
|
||||
|
||||
const title = ref(props.initialTask?.title ?? '')
|
||||
const link = ref(props.initialTask?.link ?? '')
|
||||
|
||||
const totalEstimation = computed(() =>
|
||||
steps.value.map((step) => step.estimation).reduce((a, b) => a + b, 0)
|
||||
)
|
||||
|
||||
const saveTask = () => {
|
||||
const task = new Task(id.value, title.value)
|
||||
if (link.value) {
|
||||
task.link = link.value
|
||||
}
|
||||
task.addSteps(...steps.value)
|
||||
|
||||
if (Task.validate(task)) {
|
||||
store.saveTask(task)
|
||||
router.push({
|
||||
name: 'task-view',
|
||||
params: {
|
||||
id: id.value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="task-form columns is-centered">
|
||||
<div class="column is-half">
|
||||
<h1 class="title">Create a task</h1>
|
||||
<h2 class="subtitle">
|
||||
<estimation-time-arrival :estimation="totalEstimation" />
|
||||
</h2>
|
||||
<form @submit.prevent="saveTask">
|
||||
<div class="field">
|
||||
<label class="label" for="title">Title</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" id="title" v-model="title" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="link">User story link</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" id="link" v-model="link" />
|
||||
</div>
|
||||
</div>
|
||||
<step-input v-model="steps" />
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-one-third">
|
||||
<button class="button is-primary is-fullwidth" type="submit">
|
||||
save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.task-form {
|
||||
form {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
20
src/modules/task/components/TaskNotFound.vue
Normal file
20
src/modules/task/components/TaskNotFound.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="no-task-found">
|
||||
<p>Task not found.</p>
|
||||
<router-link :to="{ name: 'home' }" class="button">
|
||||
<img src="/icons/left.svg" alt="left arrow" />
|
||||
go to homepage</router-link
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.no-task-found {
|
||||
margin: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ export const useTaskStore = defineStore('task-store', {
|
||||
}),
|
||||
actions: {
|
||||
saveTask(task: Taskable) {
|
||||
this.remove(task.id)
|
||||
this.tasks.push(task)
|
||||
},
|
||||
reset() {
|
||||
|
||||
Reference in New Issue
Block a user