Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions adminforth/modules/configValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default class ConfigValidator implements IConfigValidator {

private static readonly LOGIN_INJECTION_KEYS = ['underInputs', 'underLoginButton', 'panelHeader'];
private static readonly GLOBAL_INJECTION_KEYS = ['userMenu', 'header', 'sidebar', 'sidebarTop', 'everyPageBottom'];
private static readonly PAGE_INJECTION_KEYS = ['beforeBreadcrumbs', 'beforeActionButtons', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems', 'customActionIcons'];
private static readonly PAGE_INJECTION_KEYS = ['beforeBreadcrumbs', 'beforeActionButtons', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems', 'customActionIcons', 'customActionIconsThreeDotsMenuItems'];

constructor(private adminforth: IAdminForth, private inputConfig: AdminForthInputConfig) {
this.adminforth = adminforth;
Expand Down Expand Up @@ -404,12 +404,14 @@ export default class ConfigValidator implements IConfigValidator {
}
if (!action.showIn) {
action.showIn = {
list: true,
listQuickIcon: true,
listThreeDotsMenu: false,
showButton: false,
showThreeDotsMenu: false,
}
} else {
action.showIn.list = action.showIn.list ?? true;
action.showIn.listQuickIcon = action.showIn.listQuickIcon ?? true;
action.showIn.listThreeDotsMenu = action.showIn.listThreeDotsMenu ?? false;
action.showIn.showButton = action.showIn.showButton ?? false;
action.showIn.showThreeDotsMenu = action.showIn.showThreeDotsMenu ?? false;
}
Expand Down
218 changes: 218 additions & 0 deletions adminforth/spa/src/components/ListActionsThreeDots.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
<template>
<div class="relative inline-block">
<div
ref="triggerRef"
class="border border-gray-300 dark:border-gray-700 dark:border-opacity-0 border-opacity-0 hover:border-opacity-100 dark:hover:border-opacity-100 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
@click="toggleMenu"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yaroslav8765 can you open menu on @hover also (and close on @blur

>
<IconDotsHorizontalOutline class="w-6 h-6 text-lightPrimary dark:text-darkPrimary" />
</div>
<teleport to="body">
<div
v-if="showMenu"
ref="menuRef"
class="z-50 bg-white dark:bg-gray-900 rounded-md shadow-lg border dark:border-gray-700 py-1"
:style="menuStyles"
>
<template v-if="resourceOptions.moveBaseActionsOutOfThreeDotsMenu !== true">
<RouterLink
v-if="resourceOptions?.allowedActions?.show"
class="flex text-nowrap p-1 hover:bg-gray-100 dark:hover:bg-gray-800 w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300"
:to="{
name: 'resource-show',
params: {
resourceId: props.resourceId,
primaryKey: record._primaryKeyValue,
}
}"

>
<IconEyeSolid class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"/>
Show item
</RouterLink>

<RouterLink
v-if="resourceOptions?.allowedActions?.edit"
class="flex text-nowrap p-1 hover:bg-gray-100 dark:hover:bg-gray-800 w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300"
:to="{
name: 'resource-edit',
params: {
resourceId: props.resourceId,
primaryKey: record._primaryKeyValue,
}
}"
>
<IconPenSolid class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"/>
Edit item
</RouterLink>

<button
v-if="resourceOptions?.allowedActions?.delete"
class="flex text-nowrap p-1 hover:bg-gray-100 dark:hover:bg-gray-800 w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300"
@click="deleteRecord(record)"
>
<IconTrashBinSolid class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"/>
Delete item
</button>
</template>
<div v-for="action in (resourceOptions.actions ?? []).filter(a => a.showIn?.listThreeDotsMenu)" :key="action.id" >
<button class="flex text-nowrap p-1 hover:bg-gray-100 dark:hover:bg-gray-800 w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300" @click="() => { startCustomAction(action.id, record); showMenu = false; }">
<component v-if="action.icon" :is="getIcon(action.icon)" class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"></component>
{{ action.name }}
</button>
</div>
<template v-if="customActionIconsThreeDotsMenuItems">
<component
v-for="c in customActionIconsThreeDotsMenuItems"
:is="getCustomComponent(c)"
:meta="c.meta"
:resource="coreStore.resource"
:adminUser="coreStore.adminUser"
:record="record"
:updateRecords="props.updateRecords"
/>
</template>
</div>
</teleport>
</div>
</template>

<script lang="ts" setup>
import {
IconEyeSolid,
IconPenSolid,
IconTrashBinSolid,
IconDotsHorizontalOutline
} from '@iconify-prerendered/vue-flowbite';
import { onMounted, onBeforeUnmount, ref, nextTick, watch } from 'vue';
import { getIcon, getCustomComponent } from '@/utils';
import { useCoreStore } from '@/stores/core';
const coreStore = useCoreStore();
const showMenu = ref(false);
const triggerRef = ref<HTMLElement | null>(null);
const menuRef = ref<HTMLElement | null>(null);
const menuStyles = ref<Record<string, string>>({});
const props = defineProps<{
resourceOptions: any;
record: any;
customActionIconsThreeDotsMenuItems: any[];
resourceId: string;
deleteRecord: (record: any) => void;
updateRecords: () => void;
startCustomAction: (actionId: string, record: any) => void;
}>();
onMounted(() => {
window.addEventListener('scroll', handleScrollOrResize, true);
window.addEventListener('resize', handleScrollOrResize);
document.addEventListener('click', handleOutsideClick, true);
});
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScrollOrResize, true);
window.removeEventListener('resize', handleScrollOrResize);
document.removeEventListener('click', handleOutsideClick, true);
});
watch(showMenu, async (isOpen) => {
if (isOpen) {
await nextTick();
// First pass: after DOM mount
updateMenuPosition();
// Second pass: after layout/paint to catch width changes (fonts/icons)
requestAnimationFrame(() => {
updateMenuPosition();
// Final safety: one micro-delay retry if width was still 0
setTimeout(() => updateMenuPosition(), 0);
});
}
});
function toggleMenu() {
if (!showMenu.value) {
// Provisional position to avoid flashing at left:0 on first open
const el = triggerRef.value;
if (el) {
const rect = el.getBoundingClientRect();
const gap = 8;
menuStyles.value = {
position: 'fixed',
top: `${Math.round(rect.bottom)}px`,
left: `${Math.round(rect.left)}px`,
};
}
}
showMenu.value = !showMenu.value;
}
function updateMenuPosition() {
const el = triggerRef.value;
if (!el) return;
const rect = el.getBoundingClientRect();
const margin = 8; // gap around the trigger/menu
const menuEl = menuRef.value;
// Measure current menu size to align and decide flipping
let menuWidth = rect.width; // fallback to trigger width
let menuHeight = 0;
if (menuEl) {
const menuRect = menuEl.getBoundingClientRect();
// Prefer bounding rect; fallback to offset/scroll width if needed
const measuredW = menuRect.width || menuEl.offsetWidth || menuEl.scrollWidth;
if (measuredW > 0) menuWidth = measuredW;
const measuredH = menuRect.height || menuEl.offsetHeight || menuEl.scrollHeight;
if (measuredH > 0) menuHeight = measuredH;
}
// Right-align: right edge of menu == right edge of trigger
let left = rect.right - menuWidth;
// Clamp within viewport with small margin so it doesn't render off-screen
const minLeft = margin;
const maxLeft = Math.max(minLeft, window.innerWidth - margin - menuWidth);
left = Math.min(Math.max(left, minLeft), maxLeft);
// Determine whether to place above or below based on available space
const spaceBelow = window.innerHeight - rect.bottom - margin;
const spaceAbove = rect.top - margin;
const maxMenuHeight = Math.max(0, window.innerHeight - 2 * margin);
let top: number;
if (menuHeight === 0) {
// Unknown height yet (first pass). Prefer placing below; a subsequent pass will correct if needed.
top = rect.bottom + margin;
} else if (menuHeight <= spaceBelow) {
// Enough space below
top = rect.bottom + margin;
} else if (menuHeight <= spaceAbove) {
// Not enough below but enough above -> flip
top = rect.top - margin - menuHeight;
} else {
// Not enough space on either side: pick the side with more room and clamp within viewport
if (spaceBelow >= spaceAbove) {
top = Math.min(rect.bottom + margin, window.innerHeight - margin - menuHeight);
} else {
top = Math.max(margin, rect.top - margin - menuHeight);
}
}
menuStyles.value = {
position: 'fixed',
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
maxHeight: `${Math.round(maxMenuHeight)}px`,
overflowY: 'auto',
};
}
function handleScrollOrResize() {
showMenu.value = false;
}
function handleOutsideClick(e: MouseEvent) {
const target = e.target as Node | null;
if (!target) return;
if (menuRef.value?.contains(target)) return;
if (triggerRef.value?.contains(target)) return;
showMenu.value = false;
}
</script>
117 changes: 65 additions & 52 deletions adminforth/spa/src/components/ResourceListTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -113,57 +113,58 @@
</td>
<td class=" items-center px-2 md:px-3 lg:px-6 py-4 cursor-default" @click="(e)=>{e.stopPropagation()}">
<div class="flex text-lightPrimary dark:text-darkPrimary items-center">
<Tooltip>
<RouterLink
v-if="resource.options?.allowedActions?.show"
:to="{
name: 'resource-show',
params: {
resourceId: resource.resourceId,
primaryKey: row._primaryKeyValue,
}
}"

>
<IconEyeSolid class="af-show-icon w-5 h-5 me-2"/>
</RouterLink>

<template v-slot:tooltip>
{{ $t('Show item') }}
</template>
</Tooltip>

<Tooltip>
<RouterLink
v-if="resource.options?.allowedActions?.edit"
:to="{
name: 'resource-edit',
params: {
resourceId: resource.resourceId,
primaryKey: row._primaryKeyValue,
}
}"
>
<IconPenSolid class="af-edit-icon w-5 h-5 me-2"/>
</RouterLink>
<template v-slot:tooltip>
{{ $t('Edit item') }}
</template>
</Tooltip>

<Tooltip>
<button
v-if="resource.options?.allowedActions?.delete"
@click="deleteRecord(row)"
>
<IconTrashBinSolid class="af-delete-icon w-5 h-5 me-2"/>
</button>

<template v-slot:tooltip>
{{ $t('Delete item') }}
</template>
</Tooltip>

<template v-if="resource.options.moveBaseActionsOutOfThreeDotsMenu === true">
<Tooltip>
<RouterLink
v-if="resource.options?.allowedActions?.show"
:to="{
name: 'resource-show',
params: {
resourceId: resource.resourceId,
primaryKey: row._primaryKeyValue,
}
}"

>
<IconEyeSolid class="af-show-icon w-5 h-5 me-2"/>
</RouterLink>

<template v-slot:tooltip>
{{ $t('Show item') }}
</template>
</Tooltip>

<Tooltip>
<RouterLink
v-if="resource.options?.allowedActions?.edit"
:to="{
name: 'resource-edit',
params: {
resourceId: resource.resourceId,
primaryKey: row._primaryKeyValue,
}
}"
>
<IconPenSolid class="af-edit-icon w-5 h-5 me-2"/>
</RouterLink>
<template v-slot:tooltip>
{{ $t('Edit item') }}
</template>
</Tooltip>

<Tooltip>
<button
v-if="resource.options?.allowedActions?.delete"
@click="deleteRecord(row)"
>
<IconTrashBinSolid class="af-delete-icon w-5 h-5 me-2"/>
</button>

<template v-slot:tooltip>
{{ $t('Delete item') }}
</template>
</Tooltip>
</template>
<template v-if="customActionsInjection">
<component
v-for="c in customActionsInjection"
Expand All @@ -177,7 +178,7 @@
</template>

<template v-if="resource.options?.actions">
<Tooltip v-for="action in resource.options.actions.filter(a => a.showIn?.list)" :key="action.id">
<Tooltip v-for="action in resource.options.actions.filter(a => a.showIn?.list || a.showIn?.listQuickIcon)" :key="action.id">
<button
@click="startCustomAction(action.id, row)"
>
Expand All @@ -188,6 +189,16 @@
</template>
</Tooltip>
</template>
<ListActionsThreeDots
v-if="resource.options?.actions?.some(a => a.showIn?.listThreeDotsMenu) || (props.customActionIconsThreeDotsMenuItems && props.customActionIconsThreeDotsMenuItems.length > 0) || resource.options.moveBaseActionsOutOfThreeDotsMenu !== true"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JIC this will be reworked after making moveBaseActionsOutOfThreeDotsMenu change,
after rework it should first filter out only threeDotsmenu items and then get their length, possible to do in one-line also though

:resourceOptions="resource?.options"
:record="row"
:updateRecords="()=>emits('update:records', true)"
:deleteRecord="deleteRecord"
:resourceId="resource.resourceId"
:startCustomAction="startCustomAction"
:customActionIconsThreeDotsMenuItems="customActionIconsThreeDotsMenuItems"
/>
</div>
</td>
</tr>
Expand Down Expand Up @@ -312,6 +323,7 @@ import { Tooltip } from '@/afcl';
import type { AdminForthResourceCommon, AdminForthResourceColumnInputCommon, AdminForthResourceColumnCommon } from '@/types/Common';
import adminforth from '@/adminforth';
import Checkbox from '@/afcl/Checkbox.vue';
import ListActionsThreeDots from '@/components/ListActionsThreeDots.vue';

const coreStore = useCoreStore();
const { t } = useI18n();
Expand All @@ -326,6 +338,7 @@ const props = defineProps<{
noRoundings?: boolean,
customActionsInjection?: any[],
tableBodyStartInjection?: any[],
customActionIconsThreeDotsMenuItems?: any[]
}>();

// emits, update page
Expand Down
Loading