WIP: Redesign app layout chrome #105
@@ -47,6 +47,7 @@ class BuildProjectTicketDetailPayload
|
|||||||
'fieldValues.userValue',
|
'fieldValues.userValue',
|
||||||
'parentTicket',
|
'parentTicket',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
'ticketType',
|
'ticketType',
|
||||||
'worklogEntries.actor',
|
'worklogEntries.actor',
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ use App\Actions\Projects\BuildProjectBoardPayload;
|
|||||||
use App\Actions\Tickets\MoveProjectTicket;
|
use App\Actions\Tickets\MoveProjectTicket;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Http\Requests\MoveProjectTicketRequest;
|
use App\Http\Requests\MoveProjectTicketRequest;
|
||||||
|
use App\Http\Resources\TicketStatusResource;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@@ -42,7 +44,7 @@ class ProjectBoardController extends Controller
|
|||||||
MoveProjectTicketRequest $request,
|
MoveProjectTicketRequest $request,
|
||||||
Project $project,
|
Project $project,
|
||||||
Ticket $ticket,
|
Ticket $ticket,
|
||||||
): RedirectResponse {
|
): RedirectResponse|JsonResponse {
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
abort_unless(
|
abort_unless(
|
||||||
@@ -56,13 +58,32 @@ class ProjectBoardController extends Controller
|
|||||||
: 'projects.board.index';
|
: 'projects.board.index';
|
||||||
|
|
||||||
$result = $this->moveProjectTicket->handle($project, $ticket, $user, $validated);
|
$result = $this->moveProjectTicket->handle($project, $ticket, $user, $validated);
|
||||||
|
$statusMessage = $result['moved']
|
||||||
|
? "{$ticket->ticket_key} moved to {$result['destination_label']}."
|
||||||
|
: "{$ticket->ticket_key} already belongs to {$result['destination_label']}.";
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
$ticket->refresh()->load([
|
||||||
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
|
'ticketStatus.boardColumn',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => $statusMessage,
|
||||||
|
'moved' => $result['moved'],
|
||||||
|
'ticket_status' => $ticket->ticketStatus === null
|
||||||
|
? null
|
||||||
|
: TicketStatusResource::make($ticket->ticketStatus)->resolve(),
|
||||||
|
'ticket_status_id' => $ticket->ticket_status_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $result['moved']) {
|
if (! $result['moved']) {
|
||||||
return to_route($redirectRoute, $project)
|
return to_route($redirectRoute, $project)
|
||||||
->with('status', "{$ticket->ticket_key} already belongs to {$result['destination_label']}.");
|
->with('status', $statusMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return to_route($redirectRoute, $project)
|
return to_route($redirectRoute, $project)
|
||||||
->with('status', "{$ticket->ticket_key} moved to {$result['destination_label']}.");
|
->with('status', $statusMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,9 @@
|
|||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
Check,
|
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
IndentDecrease,
|
IndentDecrease,
|
||||||
IndentIncrease,
|
IndentIncrease,
|
||||||
LoaderCircle,
|
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-vue-next';
|
} from 'lucide-vue-next';
|
||||||
@@ -465,21 +463,7 @@ const handleIndentKeyboard = (index: number, event: KeyboardEvent): void => {
|
|||||||
<span v-else />
|
<span v-else />
|
||||||
|
|
||||||
<span
|
<span
|
||||||
v-if="saveState === 'saving'"
|
v-if="saveState === 'error' || error"
|
||||||
class="text-muted-foreground flex items-center gap-1.5 text-xs"
|
|
||||||
>
|
|
||||||
<LoaderCircle class="size-3.5 animate-spin" />
|
|
||||||
Saving
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else-if="saveState === 'saved'"
|
|
||||||
class="text-primary flex items-center gap-1.5 text-xs"
|
|
||||||
>
|
|
||||||
<Check class="size-3.5" />
|
|
||||||
Saved
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else-if="saveState === 'error' || error"
|
|
||||||
class="text-destructive flex items-center gap-1.5 text-xs"
|
class="text-destructive flex items-center gap-1.5 text-xs"
|
||||||
>
|
>
|
||||||
<CircleAlert class="size-3.5" />
|
<CircleAlert class="size-3.5" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Check, CircleAlert, LoaderCircle } from 'lucide-vue-next';
|
import { CircleAlert } from 'lucide-vue-next';
|
||||||
import type { InlineSaveStatusProps } from '@/types';
|
import type { InlineSaveStatusProps } from '@/types';
|
||||||
|
|
||||||
withDefaults(defineProps<InlineSaveStatusProps>(), {
|
withDefaults(defineProps<InlineSaveStatusProps>(), {
|
||||||
@@ -9,22 +9,10 @@ withDefaults(defineProps<InlineSaveStatusProps>(), {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="state !== 'idle' || error"
|
v-if="state === 'error' || error"
|
||||||
class="flex items-center gap-1.5 pt-1 text-[11px]"
|
class="flex items-center gap-1.5 pt-1 text-[11px]"
|
||||||
>
|
>
|
||||||
<template v-if="state === 'saving'">
|
<CircleAlert class="text-destructive size-3" />
|
||||||
<LoaderCircle class="text-muted-foreground size-3 animate-spin" />
|
<span class="text-destructive">{{ error || 'Could not save.' }}</span>
|
||||||
<span class="text-muted-foreground">Saving</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="state === 'saved'">
|
|
||||||
<Check class="text-primary size-3" />
|
|
||||||
<span class="text-primary">Saved</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="state === 'error' || error">
|
|
||||||
<CircleAlert class="text-destructive size-3" />
|
|
||||||
<span class="text-destructive">{{
|
|
||||||
error || 'Could not save.'
|
|
||||||
}}</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Check, CircleAlert, LoaderCircle, Pencil } from 'lucide-vue-next';
|
import { CircleAlert, Pencil } from 'lucide-vue-next';
|
||||||
import { nextTick, ref } from 'vue';
|
import { nextTick, ref } from 'vue';
|
||||||
import type {
|
import type {
|
||||||
InlineTextEditorEmits,
|
InlineTextEditorEmits,
|
||||||
@@ -181,25 +181,13 @@ const displayValue = (): string => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="saveState !== 'idle' || error"
|
v-if="saveState === 'error' || error"
|
||||||
class="mt-1 flex items-center gap-1.5 text-[11px]"
|
class="mt-1 flex items-center gap-1.5 text-[11px]"
|
||||||
>
|
>
|
||||||
<template v-if="saveState === 'saving'">
|
<CircleAlert class="text-destructive size-3" />
|
||||||
<LoaderCircle
|
<span class="text-destructive">{{
|
||||||
class="text-muted-foreground size-3 animate-spin"
|
error || 'Could not save.'
|
||||||
/>
|
}}</span>
|
||||||
<span class="text-muted-foreground">Saving</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="saveState === 'saved'">
|
|
||||||
<Check class="text-primary size-3" />
|
|
||||||
<span class="text-primary">Saved</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="saveState === 'error' || error">
|
|
||||||
<CircleAlert class="text-destructive size-3" />
|
|
||||||
<span class="text-destructive">{{
|
|
||||||
error || 'Could not save.'
|
|
||||||
}}</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Check, ChevronDown } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import type {
|
||||||
|
PropertySelectDropdownEmits,
|
||||||
|
PropertySelectDropdownProps,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<PropertySelectDropdownProps>(), {
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<PropertySelectDropdownEmits>();
|
||||||
|
|
||||||
|
const normalizedValue = computed(() => String(props.modelValue ?? ''));
|
||||||
|
const selectedOption = computed(
|
||||||
|
() =>
|
||||||
|
props.options.find(
|
||||||
|
(option) => String(option.value) === normalizedValue.value,
|
||||||
|
) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectOption = (value: number | string): void => {
|
||||||
|
if (
|
||||||
|
!props.editable ||
|
||||||
|
props.disabled ||
|
||||||
|
String(value) === normalizedValue.value
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:modelValue', value);
|
||||||
|
emit('commit', value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenu v-if="editable">
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="focus-visible:ring-ring/50 data-[state=open]:bg-primary/[0.08] data-[state=open]:text-primary hover:bg-primary/[0.055] hover:text-primary group -mx-1 flex min-h-7 w-[calc(100%+0.5rem)] items-center gap-2 rounded px-1 text-left text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-60"
|
||||||
|
:aria-label="`Select ${label}`"
|
||||||
|
:disabled="disabled"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate">
|
||||||
|
{{ selectedOption?.label ?? 'Not set' }}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
class="text-muted-foreground size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
:side-offset="6"
|
||||||
|
class="w-(--reka-dropdown-menu-trigger-width) min-w-52 rounded p-1"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="option in options"
|
||||||
|
:key="String(option.value)"
|
||||||
|
class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2"
|
||||||
|
:class="
|
||||||
|
normalizedValue === String(option.value)
|
||||||
|
? 'bg-primary/[0.055] text-primary'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
@select="selectOption(option.value)"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate font-medium">
|
||||||
|
{{ option.label }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="option.description"
|
||||||
|
class="text-muted-foreground block truncate text-[11px]"
|
||||||
|
>
|
||||||
|
{{ option.description }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Check
|
||||||
|
v-if="normalizedValue === String(option.value)"
|
||||||
|
class="text-primary size-4 shrink-0"
|
||||||
|
/>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex min-h-7 min-w-0 items-center gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ selectedOption?.label ?? 'Not set' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from 'lucide-vue-next';
|
} from 'lucide-vue-next';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { normalizeAcceptanceCriteria } from '@/lib/acceptance-criteria';
|
import { normalizeAcceptanceCriteria } from '@/lib/acceptance-criteria';
|
||||||
|
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||||
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
||||||
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
@@ -24,6 +25,7 @@ import InputError from '@/components/InputError.vue';
|
|||||||
import AcceptanceCriteriaEditor from '@/components/projects/AcceptanceCriteriaEditor.vue';
|
import AcceptanceCriteriaEditor from '@/components/projects/AcceptanceCriteriaEditor.vue';
|
||||||
import InlineSaveStatus from '@/components/projects/InlineSaveStatus.vue';
|
import InlineSaveStatus from '@/components/projects/InlineSaveStatus.vue';
|
||||||
import InlineTextEditor from '@/components/projects/InlineTextEditor.vue';
|
import InlineTextEditor from '@/components/projects/InlineTextEditor.vue';
|
||||||
|
import PropertySelectDropdown from '@/components/projects/PropertySelectDropdown.vue';
|
||||||
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
|
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
|
||||||
import UserSelectDropdown from '@/components/projects/UserSelectDropdown.vue';
|
import UserSelectDropdown from '@/components/projects/UserSelectDropdown.vue';
|
||||||
import TicketTreeList from '@/components/TicketTreeList.vue';
|
import TicketTreeList from '@/components/TicketTreeList.vue';
|
||||||
@@ -56,14 +58,17 @@ import type {
|
|||||||
AcceptanceCriteriaEditorCommit,
|
AcceptanceCriteriaEditorCommit,
|
||||||
AcceptanceCriteriaSaveState,
|
AcceptanceCriteriaSaveState,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
|
PropertySelectOption,
|
||||||
ProjectTicketPageProps,
|
ProjectTicketPageProps,
|
||||||
ProjectTicketUpdatePayload,
|
ProjectTicketUpdatePayload,
|
||||||
QueuedTicketAutosave,
|
QueuedTicketAutosave,
|
||||||
TicketAutosaveErrorResponse,
|
TicketAutosaveErrorResponse,
|
||||||
TicketAutosaveResponse,
|
TicketAutosaveResponse,
|
||||||
TicketEditableField,
|
TicketEditableField,
|
||||||
|
TicketStatusMoveResponse,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||||
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
|
|
||||||
const props = defineProps<ProjectTicketPageProps>();
|
const props = defineProps<ProjectTicketPageProps>();
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
@@ -102,8 +107,19 @@ const canEditAuthor = computed(
|
|||||||
const canViewAuthor = computed(
|
const canViewAuthor = computed(
|
||||||
() => props.project.show_author || props.canManageProject,
|
() => props.project.show_author || props.canManageProject,
|
||||||
);
|
);
|
||||||
|
const currentTicketStatus = ref<TicketStatusModel | null>(
|
||||||
|
props.ticket.ticket_status,
|
||||||
|
);
|
||||||
|
const selectedTicketStatusId = ref<number | string>(
|
||||||
|
props.ticket.ticket_status_id ?? '',
|
||||||
|
);
|
||||||
|
const isStatusMoving = ref(false);
|
||||||
|
const statusMoveError = ref('');
|
||||||
const activeProjectSection = computed(() =>
|
const activeProjectSection = computed(() =>
|
||||||
props.ticket.ticket_status?.board_column_id === null ? 'backlog' : 'board',
|
currentTicketStatus.value === null ||
|
||||||
|
currentTicketStatus.value.board_column_id === null
|
||||||
|
? 'backlog'
|
||||||
|
: 'board',
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultBranchRepositoryConnectionId = computed(() =>
|
const defaultBranchRepositoryConnectionId = computed(() =>
|
||||||
@@ -166,25 +182,52 @@ const form = useForm<ProjectTicketUpdatePayload>({
|
|||||||
const queuedAutosave = ref<QueuedTicketAutosave | null>(null);
|
const queuedAutosave = ref<QueuedTicketAutosave | null>(null);
|
||||||
const isAutosaveRequestActive = ref(false);
|
const isAutosaveRequestActive = ref(false);
|
||||||
const savingField = ref<TicketEditableField | null>(null);
|
const savingField = ref<TicketEditableField | null>(null);
|
||||||
const recentlySavedField = ref<TicketEditableField | null>(null);
|
|
||||||
const autosaveErrors = ref<Partial<Record<TicketEditableField, string>>>({});
|
const autosaveErrors = ref<Partial<Record<TicketEditableField, string>>>({});
|
||||||
const activeSelectField = ref<TicketEditableField | null>(null);
|
const activeSelectField = ref<TicketEditableField | null>(null);
|
||||||
|
|
||||||
let recentlySavedTimeout: number | undefined;
|
|
||||||
|
|
||||||
const currentTicketType = computed(
|
const currentTicketType = computed(
|
||||||
() =>
|
() =>
|
||||||
props.ticketTypes.find(
|
props.ticketTypes.find(
|
||||||
(ticketType) => ticketType.id === Number(form.ticket_type_id),
|
(ticketType) => ticketType.id === Number(form.ticket_type_id),
|
||||||
) ?? props.ticket.ticket_type,
|
) ?? props.ticket.ticket_type,
|
||||||
);
|
);
|
||||||
const currentTicketPriority = computed(
|
const ticketTypeOptions = computed<PropertySelectOption[]>(() =>
|
||||||
() =>
|
props.ticketTypes.map((ticketType) => ({
|
||||||
props.ticketPriorities.find(
|
label: ticketType.name,
|
||||||
(ticketPriority) =>
|
value: ticketType.id,
|
||||||
ticketPriority.id === Number(form.ticket_priority_id),
|
})),
|
||||||
) ?? props.ticket.ticket_priority,
|
|
||||||
);
|
);
|
||||||
|
const ticketPriorityOptions = computed<PropertySelectOption[]>(() =>
|
||||||
|
props.ticketPriorities.map((ticketPriority) => ({
|
||||||
|
label: ticketPriority.name,
|
||||||
|
value: ticketPriority.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const ticketStatusOptions = computed<PropertySelectOption[]>(() => {
|
||||||
|
const currentStatus = currentTicketStatus.value;
|
||||||
|
|
||||||
|
if (currentStatus === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [currentStatus, ...(currentStatus.allowed_target_statuses ?? [])]
|
||||||
|
.filter(
|
||||||
|
(ticketStatus, index, statuses) =>
|
||||||
|
statuses.findIndex(
|
||||||
|
(candidateStatus) => candidateStatus.id === ticketStatus.id,
|
||||||
|
) === index,
|
||||||
|
)
|
||||||
|
.map((ticketStatus) => ({
|
||||||
|
description:
|
||||||
|
ticketStatus.board_column !== null &&
|
||||||
|
ticketStatus.board_column !== undefined &&
|
||||||
|
ticketStatus.board_column.name !== ticketStatus.name
|
||||||
|
? ticketStatus.board_column.name
|
||||||
|
: null,
|
||||||
|
label: ticketStatus.name,
|
||||||
|
value: ticketStatus.id,
|
||||||
|
}));
|
||||||
|
});
|
||||||
const currentParentTicketOption = computed(
|
const currentParentTicketOption = computed(
|
||||||
() =>
|
() =>
|
||||||
props.parentTicketOptions.find(
|
props.parentTicketOptions.find(
|
||||||
@@ -440,20 +483,6 @@ const clearAutosaveError = (field: TicketEditableField): void => {
|
|||||||
autosaveErrors.value = nextErrors;
|
autosaveErrors.value = nextErrors;
|
||||||
};
|
};
|
||||||
|
|
||||||
const markFieldAsSaved = (field: TicketEditableField): void => {
|
|
||||||
recentlySavedField.value = field;
|
|
||||||
|
|
||||||
if (recentlySavedTimeout !== undefined) {
|
|
||||||
window.clearTimeout(recentlySavedTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
recentlySavedTimeout = window.setTimeout(() => {
|
|
||||||
if (recentlySavedField.value === field) {
|
|
||||||
recentlySavedField.value = null;
|
|
||||||
}
|
|
||||||
}, 1800);
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractAutosaveError = async (response: Response): Promise<string> => {
|
const extractAutosaveError = async (response: Response): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const body = (await response.json()) as TicketAutosaveErrorResponse;
|
const body = (await response.json()) as TicketAutosaveErrorResponse;
|
||||||
@@ -525,8 +554,6 @@ const runAutosaveQueue = async (): Promise<void> => {
|
|||||||
result.acceptance_criteria,
|
result.acceptance_criteria,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
markFieldAsSaved(request.field);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
autosaveErrors.value = {
|
autosaveErrors.value = {
|
||||||
...autosaveErrors.value,
|
...autosaveErrors.value,
|
||||||
@@ -572,7 +599,7 @@ const saveStateFor = (
|
|||||||
return 'error';
|
return 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
return recentlySavedField.value === field ? 'saved' : 'idle';
|
return 'idle';
|
||||||
};
|
};
|
||||||
|
|
||||||
const errorFor = (field: TicketEditableField): string =>
|
const errorFor = (field: TicketEditableField): string =>
|
||||||
@@ -602,6 +629,60 @@ const commitSelectField = (field: TicketEditableField): void => {
|
|||||||
requestTicketAutosave(field);
|
requestTicketAutosave(field);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const moveTicketStatus = async (value: number | string): Promise<void> => {
|
||||||
|
if (!props.canEditTicket || isStatusMoving.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousStatusId = currentTicketStatus.value?.id ?? '';
|
||||||
|
const csrfToken = document
|
||||||
|
.querySelector('meta[name="csrf-token"]')
|
||||||
|
?.getAttribute('content');
|
||||||
|
|
||||||
|
isStatusMoving.value = true;
|
||||||
|
statusMoveError.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
ProjectBoardController.move.url({
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': csrfToken ?? '',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
redirect_to: activeProjectSection.value,
|
||||||
|
ticket_status_id: Number(value),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await extractAutosaveError(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await response.json()) as TicketStatusMoveResponse;
|
||||||
|
|
||||||
|
currentTicketStatus.value = result.ticket_status;
|
||||||
|
selectedTicketStatusId.value = result.ticket_status_id ?? '';
|
||||||
|
} catch (error) {
|
||||||
|
selectedTicketStatusId.value = previousStatusId;
|
||||||
|
statusMoveError.value =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'The ticket status could not be changed.';
|
||||||
|
} finally {
|
||||||
|
isStatusMoving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const commitAcceptanceCriteria = (
|
const commitAcceptanceCriteria = (
|
||||||
commit: AcceptanceCriteriaEditorCommit,
|
commit: AcceptanceCriteriaEditorCommit,
|
||||||
): void => {
|
): void => {
|
||||||
@@ -641,11 +722,11 @@ const formatDateTime = (value: string): string => {
|
|||||||
{{ currentTicketType.name }}
|
{{ currentTicketType.name }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="ticket.ticket_status"
|
v-if="currentTicketStatus"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="rounded"
|
class="rounded"
|
||||||
>
|
>
|
||||||
{{ ticket.ticket_status.name }}
|
{{ currentTicketStatus.name }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="form.is_blocked"
|
v-if="form.is_blocked"
|
||||||
@@ -828,12 +909,23 @@ const formatDateTime = (value: string): string => {
|
|||||||
<div
|
<div
|
||||||
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
|
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground text-xs"
|
<span class="text-muted-foreground pt-1 text-xs"
|
||||||
>Status</span
|
>Status</span
|
||||||
>
|
>
|
||||||
<span class="text-sm font-medium">{{
|
<div class="min-w-0">
|
||||||
ticket.ticket_status?.name ?? 'Backlog'
|
<PropertySelectDropdown
|
||||||
}}</span>
|
v-model="selectedTicketStatusId"
|
||||||
|
:options="ticketStatusOptions"
|
||||||
|
:editable="canEditTicket"
|
||||||
|
:disabled="isStatusMoving"
|
||||||
|
label="Status"
|
||||||
|
@commit="moveTicketStatus"
|
||||||
|
/>
|
||||||
|
<InlineSaveStatus
|
||||||
|
:state="statusMoveError ? 'error' : 'idle'"
|
||||||
|
:error="statusMoveError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -867,39 +959,15 @@ const formatDateTime = (value: string): string => {
|
|||||||
>Type</span
|
>Type</span
|
||||||
>
|
>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<select
|
<PropertySelectDropdown
|
||||||
v-if="
|
|
||||||
activeSelectField === 'ticket_type_id'
|
|
||||||
"
|
|
||||||
v-model="form.ticket_type_id"
|
v-model="form.ticket_type_id"
|
||||||
autofocus
|
:options="ticketTypeOptions"
|
||||||
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 w-full rounded border px-2 py-1.5 text-sm outline-none focus-visible:ring-[3px]"
|
:editable="canEditTicket"
|
||||||
@change="
|
label="Type"
|
||||||
|
@commit="
|
||||||
commitSelectField('ticket_type_id')
|
commitSelectField('ticket_type_id')
|
||||||
"
|
"
|
||||||
@blur="activeSelectField = null"
|
/>
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="ticketType in ticketTypes"
|
|
||||||
:key="ticketType.id"
|
|
||||||
:value="ticketType.id"
|
|
||||||
>
|
|
||||||
{{ ticketType.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
v-else-if="canEditTicket"
|
|
||||||
type="button"
|
|
||||||
class="hover:text-primary focus-visible:ring-ring min-h-7 w-full rounded text-left text-sm font-medium outline-none focus-visible:ring-2"
|
|
||||||
@click="
|
|
||||||
activeSelectField = 'ticket_type_id'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ currentTicketType.name }}
|
|
||||||
</button>
|
|
||||||
<p v-else class="min-h-7 text-sm font-medium">
|
|
||||||
{{ currentTicketType.name }}
|
|
||||||
</p>
|
|
||||||
<InlineSaveStatus
|
<InlineSaveStatus
|
||||||
:state="saveStateFor('ticket_type_id')"
|
:state="saveStateFor('ticket_type_id')"
|
||||||
:error="errorFor('ticket_type_id')"
|
:error="errorFor('ticket_type_id')"
|
||||||
@@ -914,40 +982,15 @@ const formatDateTime = (value: string): string => {
|
|||||||
>Priority</span
|
>Priority</span
|
||||||
>
|
>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<select
|
<PropertySelectDropdown
|
||||||
v-if="
|
|
||||||
activeSelectField ===
|
|
||||||
'ticket_priority_id'
|
|
||||||
"
|
|
||||||
v-model="form.ticket_priority_id"
|
v-model="form.ticket_priority_id"
|
||||||
autofocus
|
:options="ticketPriorityOptions"
|
||||||
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 w-full rounded border px-2 py-1.5 text-sm outline-none focus-visible:ring-[3px]"
|
:editable="canEditTicket"
|
||||||
@change="
|
label="Priority"
|
||||||
|
@commit="
|
||||||
commitSelectField('ticket_priority_id')
|
commitSelectField('ticket_priority_id')
|
||||||
"
|
"
|
||||||
@blur="activeSelectField = null"
|
/>
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="ticketPriority in ticketPriorities"
|
|
||||||
:key="ticketPriority.id"
|
|
||||||
:value="ticketPriority.id"
|
|
||||||
>
|
|
||||||
{{ ticketPriority.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
v-else-if="canEditTicket"
|
|
||||||
type="button"
|
|
||||||
class="hover:text-primary focus-visible:ring-ring min-h-7 w-full rounded text-left text-sm font-medium outline-none focus-visible:ring-2"
|
|
||||||
@click="
|
|
||||||
activeSelectField = 'ticket_priority_id'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ currentTicketPriority.name }}
|
|
||||||
</button>
|
|
||||||
<p v-else class="min-h-7 text-sm font-medium">
|
|
||||||
{{ currentTicketPriority.name }}
|
|
||||||
</p>
|
|
||||||
<InlineSaveStatus
|
<InlineSaveStatus
|
||||||
:state="saveStateFor('ticket_priority_id')"
|
:state="saveStateFor('ticket_priority_id')"
|
||||||
:error="errorFor('ticket_priority_id')"
|
:error="errorFor('ticket_priority_id')"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type AcceptanceCriteriaEditorCommit = {
|
|||||||
source: AcceptanceCriteriaCommitSource;
|
source: AcceptanceCriteriaCommitSource;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AcceptanceCriteriaSaveState = 'error' | 'idle' | 'saved' | 'saving';
|
export type AcceptanceCriteriaSaveState = 'error' | 'idle' | 'saving';
|
||||||
|
|
||||||
export type AcceptanceCriteriaEditorProps = {
|
export type AcceptanceCriteriaEditorProps = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export * from './layout';
|
|||||||
export * from './navigation';
|
export * from './navigation';
|
||||||
export * from './notification-settings';
|
export * from './notification-settings';
|
||||||
export * from './page-props';
|
export * from './page-props';
|
||||||
|
export * from './property-select-dropdown';
|
||||||
export * from './project-navigation';
|
export * from './project-navigation';
|
||||||
export * from './ticket-inline-edit';
|
export * from './ticket-inline-edit';
|
||||||
export * from './ui';
|
export * from './ui';
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export type PropertySelectDropdownValue = number | string | null;
|
||||||
|
|
||||||
|
export type PropertySelectOption = {
|
||||||
|
description?: string | null;
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PropertySelectDropdownProps = {
|
||||||
|
disabled?: boolean;
|
||||||
|
editable: boolean;
|
||||||
|
label: string;
|
||||||
|
modelValue: PropertySelectDropdownValue;
|
||||||
|
options: PropertySelectOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PropertySelectDropdownEmits = {
|
||||||
|
commit: [value: number | string];
|
||||||
|
'update:modelValue': [value: number | string];
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||||
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
|
|
||||||
export type InlineTextEditorValue = number | string | null;
|
export type InlineTextEditorValue = number | string | null;
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ export type InlineTextEditorProps = {
|
|||||||
inputType?: 'number' | 'text';
|
inputType?: 'number' | 'text';
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
saveState?: 'error' | 'idle' | 'saved' | 'saving';
|
saveState?: 'error' | 'idle' | 'saving';
|
||||||
value: InlineTextEditorValue;
|
value: InlineTextEditorValue;
|
||||||
variant?: 'body' | 'property' | 'title';
|
variant?: 'body' | 'property' | 'title';
|
||||||
};
|
};
|
||||||
@@ -21,7 +22,7 @@ export type InlineTextEditorEmits = {
|
|||||||
|
|
||||||
export type InlineSaveStatusProps = {
|
export type InlineSaveStatusProps = {
|
||||||
error?: string;
|
error?: string;
|
||||||
state: 'error' | 'idle' | 'saved' | 'saving';
|
state: 'error' | 'idle' | 'saving';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TicketEditableField =
|
export type TicketEditableField =
|
||||||
@@ -70,6 +71,13 @@ export type TicketAutosaveErrorResponse = {
|
|||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TicketStatusMoveResponse = {
|
||||||
|
message: string;
|
||||||
|
moved: boolean;
|
||||||
|
ticket_status: TicketStatusModel | null;
|
||||||
|
ticket_status_id: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type QueuedTicketAutosave = {
|
export type QueuedTicketAutosave = {
|
||||||
field: TicketEditableField;
|
field: TicketEditableField;
|
||||||
payload: ProjectTicketUpdatePayload;
|
payload: ProjectTicketUpdatePayload;
|
||||||
|
|||||||
@@ -155,6 +155,75 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure inline status moves return the destination and its allowed follow-up transitions.
|
||||||
|
*/
|
||||||
|
public function test_ticket_status_moves_return_json_with_follow_up_transitions(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'CORE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
|
$todoColumn = BoardColumn::factory()->for($project)->create([
|
||||||
|
'name' => 'Todo',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$reviewColumn = BoardColumn::factory()->for($project)->create([
|
||||||
|
'name' => 'Review',
|
||||||
|
'position' => 2,
|
||||||
|
]);
|
||||||
|
$todoStatus = $this->createColumnStatus($todoColumn);
|
||||||
|
$reviewStatus = $this->createColumnStatus($reviewColumn);
|
||||||
|
$backlogStatus = $this->backlogStatus($project);
|
||||||
|
$this->allowMove($backlogStatus, $todoStatus);
|
||||||
|
$this->allowMove($todoStatus, $reviewStatus);
|
||||||
|
|
||||||
|
$priority = TicketPriority::factory()->for($project)->create([
|
||||||
|
'name' => 'High',
|
||||||
|
'position' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ticket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'CORE-1',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
|
'title' => 'Inline status ticket',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patchJson(route('projects.tickets.status.update', [$project, $ticket]), [
|
||||||
|
'redirect_to' => 'backlog',
|
||||||
|
'ticket_status_id' => $todoStatus->id,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('message', 'CORE-1 moved to Todo.')
|
||||||
|
->assertJsonPath('moved', true)
|
||||||
|
->assertJsonPath('ticket_status.id', $todoStatus->id)
|
||||||
|
->assertJsonPath('ticket_status.board_column.id', $todoColumn->id)
|
||||||
|
->assertJsonPath('ticket_status.allowed_target_statuses.0.id', $reviewStatus->id)
|
||||||
|
->assertJsonPath('ticket_status.allowed_target_statuses.0.board_column.id', $reviewColumn->id)
|
||||||
|
->assertJsonPath('ticket_status_id', $todoStatus->id);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tickets', [
|
||||||
|
'id' => $ticket->id,
|
||||||
|
'ticket_status_id' => $todoStatus->id,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||||
|
'actor_id' => $user->id,
|
||||||
|
'entry_type' => 'status_changed',
|
||||||
|
'summary' => 'Moved from Backlog to Todo.',
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure board tickets can move between columns.
|
* Ensure board tickets can move between columns.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -70,6 +70,21 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
'ticket_type_id' => $this->nonParentType($project)->id,
|
'ticket_type_id' => $this->nonParentType($project)->id,
|
||||||
'title' => 'Ticket detail test',
|
'title' => 'Ticket detail test',
|
||||||
]);
|
]);
|
||||||
|
$targetColumn = BoardColumn::factory()->for($project)->create([
|
||||||
|
'name' => 'In Progress',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$targetStatus = TicketStatus::factory()
|
||||||
|
->for($project)
|
||||||
|
->for($targetColumn)
|
||||||
|
->create([
|
||||||
|
'name' => 'In Progress',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
TicketStatus::query()
|
||||||
|
->findOrFail($ticket->ticket_status_id)
|
||||||
|
->allowedTargetStatuses()
|
||||||
|
->syncWithoutDetaching([$targetStatus->id]);
|
||||||
$rootCriterion = AcceptanceCriterion::query()->create([
|
$rootCriterion = AcceptanceCriterion::query()->create([
|
||||||
'ticket_id' => $ticket->id,
|
'ticket_id' => $ticket->id,
|
||||||
'parent_acceptance_criterion_id' => null,
|
'parent_acceptance_criterion_id' => null,
|
||||||
@@ -131,6 +146,8 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
->where('ticket.acceptance_criteria.1.checked', true)
|
->where('ticket.acceptance_criteria.1.checked', true)
|
||||||
->where('ticket.acceptance_criteria.1.indent', 1)
|
->where('ticket.acceptance_criteria.1.indent', 1)
|
||||||
->where('ticket.assignee.name', 'Assigned User')
|
->where('ticket.assignee.name', 'Assigned User')
|
||||||
|
->where('ticket.ticket_status.allowed_target_statuses.0.id', $targetStatus->id)
|
||||||
|
->where('ticket.ticket_status.allowed_target_statuses.0.board_column.id', $targetColumn->id)
|
||||||
->where('ticket.author', null)
|
->where('ticket.author', null)
|
||||||
->where('ticket.author_user_id', null)
|
->where('ticket.author_user_id', null)
|
||||||
->where('ticket.field_values.0.ticket_field_definition.label', 'Complexity')
|
->where('ticket.field_values.0.ticket_field_definition.label', 'Complexity')
|
||||||
|
|||||||
Reference in New Issue
Block a user