Refine mobile layout for project resource pages by making breadcrumbs and status blocks responsive, improving dropdown and checkbox touch behavior, and adding support for custom modal triggers. Add feature tests covering breadcrumb visibility and responsive checkbox layout.
417 lines
23 KiB
PHP
417 lines
23 KiB
PHP
@props([
|
|
'title' => 'Are you sure?',
|
|
'isErrorButton' => false,
|
|
'isHighlightedButton' => false,
|
|
'buttonTitle' => 'Confirm Action',
|
|
'buttonFullWidth' => false,
|
|
'customButton' => null,
|
|
'disabled' => false,
|
|
'dispatchAction' => false,
|
|
'submitAction' => 'delete',
|
|
'content' => null,
|
|
'checkboxes' => [],
|
|
'actions' => [],
|
|
'warningMessage' => null,
|
|
'confirmWithText' => true,
|
|
'confirmationText' => 'Confirm Deletion',
|
|
'confirmationLabel' => 'Please confirm the execution of the actions by entering the Name below',
|
|
'shortConfirmationLabel' => 'Name',
|
|
'confirmWithPassword' => true,
|
|
'step1ButtonText' => 'Continue',
|
|
'step2ButtonText' => 'Continue',
|
|
'step3ButtonText' => 'Confirm',
|
|
'dispatchEvent' => false,
|
|
'dispatchEventType' => 'success',
|
|
'dispatchEventMessage' => '',
|
|
'ignoreWire' => true,
|
|
'temporaryDisableTwoStepConfirmation' => false,
|
|
])
|
|
|
|
@php
|
|
use App\Models\InstanceSettings;
|
|
// Global setting to disable ALL two-step confirmation (text + password)
|
|
$disableTwoStepConfirmation = data_get(InstanceSettings::get(), 'disable_two_step_confirmation');
|
|
// Skip ONLY password confirmation for OAuth users (they have no password)
|
|
$skipPasswordConfirmation = shouldSkipPasswordConfirmation();
|
|
if ($temporaryDisableTwoStepConfirmation) {
|
|
$disableTwoStepConfirmation = false;
|
|
// Password confirmation requirement is not affected by temporary two-step disable
|
|
}
|
|
// When password step is skipped, Step 2 becomes final - change button text from "Continue" to "Confirm"
|
|
$effectiveStep2ButtonText = ($skipPasswordConfirmation && $step2ButtonText === 'Continue') ? 'Confirm' : $step2ButtonText;
|
|
@endphp
|
|
|
|
<div {{ $ignoreWire ? 'wire:ignore' : '' }} x-data="{
|
|
modalOpen: false,
|
|
step: {{ empty($checkboxes) ? 2 : 1 }},
|
|
initialStep: {{ empty($checkboxes) ? 2 : 1 }},
|
|
finalStep: {{ $confirmWithPassword && !$skipPasswordConfirmation ? 3 : 2 }},
|
|
deleteText: '',
|
|
password: '',
|
|
actions: @js($actions),
|
|
confirmationText: (() => {
|
|
const textarea = document.createElement('textarea');
|
|
textarea.innerHTML = @js($confirmationText);
|
|
return textarea.value;
|
|
})(),
|
|
userConfirmationText: '',
|
|
confirmWithText: @js($confirmWithText && !$disableTwoStepConfirmation),
|
|
confirmWithPassword: @js($confirmWithPassword && !$skipPasswordConfirmation),
|
|
submitAction: @js($submitAction),
|
|
dispatchAction: @js($dispatchAction),
|
|
submitting: false,
|
|
passwordError: '',
|
|
selectedActions: @js(collect($checkboxes)->pluck('id')->filter(fn($id) => $this->$id)->values()->all()),
|
|
dispatchEvent: @js($dispatchEvent),
|
|
dispatchEventType: @js($dispatchEventType),
|
|
dispatchEventMessage: @js($dispatchEventMessage),
|
|
disableTwoStepConfirmation: @js($disableTwoStepConfirmation),
|
|
skipPasswordConfirmation: @js($skipPasswordConfirmation),
|
|
resetModal() {
|
|
this.step = this.initialStep;
|
|
this.deleteText = '';
|
|
this.password = '';
|
|
this.submitting = false;
|
|
this.userConfirmationText = '';
|
|
this.selectedActions = @js(collect($checkboxes)->pluck('id')->filter(fn($id) => $this->$id)->values()->all());
|
|
$wire.$refresh();
|
|
},
|
|
step1ButtonText: @js($step1ButtonText),
|
|
step2ButtonText: @js($effectiveStep2ButtonText),
|
|
step3ButtonText: @js($step3ButtonText),
|
|
validatePassword() {
|
|
if (this.confirmWithPassword && !this.password) {
|
|
return 'Password is required.';
|
|
}
|
|
return '';
|
|
},
|
|
submitForm() {
|
|
if (this.confirmWithPassword) {
|
|
this.passwordError = this.validatePassword();
|
|
if (this.passwordError) {
|
|
return Promise.resolve(this.passwordError);
|
|
}
|
|
}
|
|
if (this.dispatchAction) {
|
|
$wire.dispatch(this.submitAction);
|
|
return Promise.resolve(true);
|
|
}
|
|
|
|
const methodName = this.submitAction.split('(')[0];
|
|
const paramsMatch = this.submitAction.match(/\((.*?)\)/);
|
|
const params = paramsMatch ? paramsMatch[1].split(',').map(param => param.trim()) : [];
|
|
|
|
// Always pass password parameter (empty string if password confirmation is skipped)
|
|
// This ensures consistent method signature for backend Livewire methods
|
|
params.push(this.confirmWithPassword ? this.password : '');
|
|
|
|
// Only pass selectedActions if there are checkboxes with selections
|
|
if (this.selectedActions.length > 0) {
|
|
params.push(this.selectedActions);
|
|
}
|
|
return $wire[methodName](...params)
|
|
.then(result => {
|
|
if (result === true) {
|
|
return true;
|
|
} else if (typeof result === 'string') {
|
|
return result;
|
|
}
|
|
});
|
|
},
|
|
toggleAction(id) {
|
|
const index = this.selectedActions.indexOf(id);
|
|
if (index > -1) {
|
|
this.selectedActions.splice(index, 1);
|
|
} else {
|
|
this.selectedActions.push(id);
|
|
}
|
|
}
|
|
}"
|
|
@keydown.escape.window="if (modalOpen) { modalOpen = false; resetModal(); }" :class="{ 'z-40': modalOpen }"
|
|
class="relative w-auto h-auto">
|
|
@if (isset($trigger))
|
|
<div @click="modalOpen=true">
|
|
{{ $trigger }}
|
|
</div>
|
|
@elseif ($customButton)
|
|
@if ($buttonFullWidth)
|
|
<x-forms.button @click="modalOpen=true" class="w-full">
|
|
{{ $customButton }}
|
|
</x-forms.button>
|
|
@else
|
|
<x-forms.button @click="modalOpen=true">
|
|
{{ $customButton }}
|
|
</x-forms.button>
|
|
@endif
|
|
@else
|
|
@if ($content)
|
|
<div @click="modalOpen=true">
|
|
{{ $content }}
|
|
</div>
|
|
@else
|
|
@if ($disabled)
|
|
@if ($buttonFullWidth)
|
|
<x-forms.button class="w-full" isError disabled wire:target>
|
|
{{ $buttonTitle }}
|
|
</x-forms.button>
|
|
@else
|
|
<x-forms.button isError disabled wire:target>
|
|
{{ $buttonTitle }}
|
|
</x-forms.button>
|
|
@endif
|
|
@elseif ($isErrorButton)
|
|
@if ($buttonFullWidth)
|
|
<x-forms.button class="w-full" isError @click="modalOpen=true">
|
|
{{ $buttonTitle }}
|
|
</x-forms.button>
|
|
@else
|
|
<x-forms.button isError @click="modalOpen=true">
|
|
{{ $buttonTitle }}
|
|
</x-forms.button>
|
|
@endif
|
|
@elseif($isHighlightedButton)
|
|
@if ($buttonFullWidth)
|
|
<x-forms.button @click="modalOpen=true" class="flex gap-2 w-full" isHighlighted wire:target>
|
|
{{ $buttonTitle }}
|
|
</x-forms.button>
|
|
@else
|
|
<x-forms.button @click="modalOpen=true" class="flex gap-2" isHighlighted wire:target>
|
|
{{ $buttonTitle }}
|
|
</x-forms.button>
|
|
@endif
|
|
@else
|
|
@if ($buttonFullWidth)
|
|
<x-forms.button @click="modalOpen=true" class="flex gap-2 w-full" wire:target>
|
|
{{ $buttonTitle }}
|
|
</x-forms.button>
|
|
@else
|
|
<x-forms.button @click="modalOpen=true" class="flex gap-2" wire:target>
|
|
{{ $buttonTitle }}
|
|
</x-forms.button>
|
|
@endif
|
|
@endif
|
|
@endif
|
|
@endif
|
|
<template x-teleport="body">
|
|
<div x-show="modalOpen"
|
|
class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-0 sm:p-4" x-cloak>
|
|
<div x-show="modalOpen" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs">
|
|
</div>
|
|
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
|
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
|
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
|
x-transition:leave="ease-in duration-100"
|
|
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
|
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
|
class="relative w-full border rounded-none sm:rounded-sm min-w-full lg:min-w-[36rem] max-w-full sm:max-w-[48rem] h-screen sm:h-auto max-h-screen sm:max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col">
|
|
<div class="flex justify-between items-center py-6 px-7 shrink-0">
|
|
<h3 class="pr-8 text-2xl font-bold">{{ $title }}</h3>
|
|
<button @click="modalOpen = false; resetModal()"
|
|
class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
|
|
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
|
stroke-width="1.5" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="relative w-auto overflow-y-auto px-7 pb-6" style="-webkit-overflow-scrolling: touch;">
|
|
@if (!empty($checkboxes))
|
|
<!-- Step 1: Select actions -->
|
|
<div x-show="step === 1">
|
|
@foreach ($checkboxes as $index => $checkbox)
|
|
<div class="flex justify-between items-center mb-2">
|
|
<x-forms.checkbox fullWidth :label="$checkbox['label']" :id="$checkbox['id']"
|
|
:wire:model="$checkbox['id']"
|
|
x-on:change="toggleAction('{{ $checkbox['id'] }}')" :checked="$this->{$checkbox['id']}"
|
|
x-bind:checked="selectedActions.includes('{{ $checkbox['id'] }}')" />
|
|
</div>
|
|
@endforeach
|
|
|
|
<div class="flex flex-wrap gap-2 justify-between mt-4">
|
|
<x-forms.button @click="modalOpen = false; resetModal()"
|
|
class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
|
|
Cancel
|
|
</x-forms.button>
|
|
<x-forms.button @click="step++" class="w-auto" isError>
|
|
<span x-text="step1ButtonText"></span>
|
|
</x-forms.button>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<!-- Step 2: Confirm deletion -->
|
|
<div x-show="step === 2">
|
|
<x-callout type="danger" title="Warning" class="mb-4">
|
|
{!! $warningMessage ?: 'This operation is permanent and cannot be undone. Please think again before proceeding!' !!}
|
|
</x-callout>
|
|
<div class="mb-4">The following actions will be performed:</div>
|
|
<ul class="mb-4 space-y-2">
|
|
@foreach ($actions as $action)
|
|
<li class="flex items-center text-red-500">
|
|
<svg class="shrink-0 mr-2 w-5 h-5" fill="none" stroke="currentColor"
|
|
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
<span>{{ $action }}</span>
|
|
</li>
|
|
@endforeach
|
|
@foreach ($checkboxes as $checkbox)
|
|
<template x-if="selectedActions.includes('{{ $checkbox['id'] }}')">
|
|
<li class="flex items-center text-red-500">
|
|
<svg class="shrink-0 mr-2 w-5 h-5" fill="none" stroke="currentColor"
|
|
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
<span>{{ $checkbox['label'] }}</span>
|
|
</li>
|
|
</template>
|
|
@if (isset($checkbox['default_warning']))
|
|
<template x-if="!selectedActions.includes('{{ $checkbox['id'] }}')">
|
|
<li class="flex items-center text-red-500">
|
|
<svg class="shrink-0 mr-2 w-5 h-5" fill="none" stroke="currentColor"
|
|
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
<span>{{ $checkbox['default_warning'] }}</span>
|
|
</li>
|
|
</template>
|
|
@endif
|
|
@endforeach
|
|
</ul>
|
|
@if (!$disableTwoStepConfirmation)
|
|
@if ($confirmWithText)
|
|
<div class="mb-4">
|
|
<h4 class="mb-2 text-lg font-semibold">Confirm Actions</h4>
|
|
<p class="mb-2 text-sm">{{ $confirmationLabel }}</p>
|
|
<div class="relative mb-2" x-data="{ decodedText: confirmationText }">
|
|
<div class="relative">
|
|
<input type="text" x-model="decodedText" readonly class="input">
|
|
<button x-show="window.isSecureContext"
|
|
@click.prevent="navigator.clipboard.writeText(decodedText); $el.innerHTML = '<svg class=\'w-5 h-5 text-green-500\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M5 13l4 4L19 7\' /></svg>'; setTimeout(() => $el.innerHTML = '<svg class=\'w-5 h-5\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\' /></svg>', 1000)"
|
|
class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-gray-300 transition-colors"
|
|
title="Copy to clipboard">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor"
|
|
viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<label for="userConfirmationText"
|
|
class="block mt-4 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{{ $shortConfirmationLabel }}
|
|
</label>
|
|
<input type="text" x-model="userConfirmationText"
|
|
class="p-2 mt-1 px-3 w-full rounded-sm input">
|
|
</div>
|
|
@endif
|
|
@endif
|
|
|
|
<div class="flex flex-wrap gap-2 justify-between mt-4">
|
|
@if (!empty($checkboxes))
|
|
<x-forms.button @click="step--"
|
|
class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
|
|
Back
|
|
</x-forms.button>
|
|
@else
|
|
<x-forms.button @click="modalOpen = false; resetModal()"
|
|
class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
|
|
Cancel
|
|
</x-forms.button>
|
|
@endif
|
|
<x-forms.button
|
|
x-bind:disabled="submitting || (!disableTwoStepConfirmation && confirmWithText && userConfirmationText !==
|
|
confirmationText)"
|
|
class="w-auto" isError
|
|
@click="
|
|
if (dispatchEvent) {
|
|
$wire.dispatch(dispatchEventType, dispatchEventMessage);
|
|
}
|
|
if (confirmWithPassword && !skipPasswordConfirmation) {
|
|
step++;
|
|
} else {
|
|
submitting = true;
|
|
submitForm().then((result) => {
|
|
submitting = false;
|
|
modalOpen = false;
|
|
resetModal();
|
|
}).catch(() => {
|
|
submitting = false;
|
|
});
|
|
}
|
|
">
|
|
<span x-show="!submitting" x-text="step2ButtonText"></span>
|
|
<x-loading x-show="submitting" text="Processing..." />
|
|
</x-forms.button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3: Password confirmation -->
|
|
@if (!$skipPasswordConfirmation)
|
|
<div x-show="step === 3 && confirmWithPassword">
|
|
<x-callout type="danger" title="Final Confirmation" class="mb-4">
|
|
Please enter your password to confirm this destructive action.
|
|
</x-callout>
|
|
<div class="flex flex-col gap-2 mb-4">
|
|
@php
|
|
$passwordConfirm = Str::uuid();
|
|
@endphp
|
|
<label for="password-confirm-{{ $passwordConfirm }}"
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Your Password
|
|
</label>
|
|
<form @submit.prevent="false" @keydown.enter.prevent>
|
|
<input type="text" name="username" autocomplete="username"
|
|
value="{{ auth()->user()->email }}" style="display: none;">
|
|
<input type="password" id="password-confirm-{{ $passwordConfirm }}"
|
|
x-model="password" class="w-full input" placeholder="Enter your password"
|
|
autocomplete="current-password">
|
|
</form>
|
|
<p x-show="passwordError" x-text="passwordError" class="mt-1 text-sm text-red-500">
|
|
</p>
|
|
@error('password')
|
|
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
|
|
@enderror
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2 justify-between mt-4">
|
|
<x-forms.button @click="step--"
|
|
class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
|
|
Back
|
|
</x-forms.button>
|
|
<x-forms.button x-bind:disabled="!password || submitting" class="w-auto" isError
|
|
@click="
|
|
if (dispatchEvent) {
|
|
$wire.dispatch(dispatchEventType, dispatchEventMessage);
|
|
}
|
|
submitting = true;
|
|
submitForm().then((result) => {
|
|
submitting = false;
|
|
if (result === true) {
|
|
modalOpen = false;
|
|
resetModal();
|
|
} else {
|
|
passwordError = result;
|
|
password = '';
|
|
}
|
|
}).catch(() => {
|
|
submitting = false;
|
|
});
|
|
">
|
|
<span x-show="!submitting" x-text="step3ButtonText"></span>
|
|
<x-loading x-show="submitting" text="Processing..." />
|
|
</x-forms.button>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|