refactor: simplify organizer operations

This commit is contained in:
Pujit Mehrotra
2025-10-13 12:48:33 -04:00
parent 11ee93cae0
commit fd802ea2d8
17 changed files with 841 additions and 409 deletions
+17
View File
@@ -1155,6 +1155,7 @@ type ResolvedOrganizerView {
id: String!
name: String!
root: ResolvedOrganizerEntry!
flatEntries: [FlatOrganizerEntry!]!
prefs: JSON
}
@@ -1186,6 +1187,19 @@ type ResolvedOrganizerV1 {
views: [ResolvedOrganizerView!]!
}
type FlatOrganizerEntry {
id: String!
type: String!
name: String!
parentId: String
depth: Float!
position: Float!
path: [String!]!
hasChildren: Boolean!
childrenIds: [String!]!
meta: DockerContainer
}
type FlashBackupStatus {
"""Status message indicating the outcome of the backup initiation."""
status: String!
@@ -2469,6 +2483,9 @@ type Mutation {
setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1!
deleteDockerEntries(entryIds: [String!]!): ResolvedOrganizerV1!
moveDockerEntriesToFolder(sourceEntryIds: [String!]!, destinationFolderId: String!): ResolvedOrganizerV1!
moveDockerItemsToPosition(sourceEntryIds: [String!]!, destinationFolderId: String!, position: Float!): ResolvedOrganizerV1!
renameDockerFolder(folderId: String!, newName: String!): ResolvedOrganizerV1!
createDockerFolderWithItems(name: String!, parentId: String, sourceEntryIds: [String!], position: Float): ResolvedOrganizerV1!
refreshDockerDigests: Boolean!
"""Initiates a flash drive backup using a configured remote."""
@@ -153,6 +153,63 @@ export class DockerResolver {
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
@Mutation(() => ResolvedOrganizerV1)
public async moveDockerItemsToPosition(
@Args('sourceEntryIds', { type: () => [String] }) sourceEntryIds: string[],
@Args('destinationFolderId') destinationFolderId: string,
@Args('position', { type: () => Number }) position: number
) {
const organizer = await this.dockerOrganizerService.moveItemsToPosition({
sourceEntryIds,
destinationFolderId,
position,
});
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
@Mutation(() => ResolvedOrganizerV1)
public async renameDockerFolder(
@Args('folderId') folderId: string,
@Args('newName') newName: string
) {
const organizer = await this.dockerOrganizerService.renameFolderById({
folderId,
newName,
});
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
@Mutation(() => ResolvedOrganizerV1)
public async createDockerFolderWithItems(
@Args('name') name: string,
@Args('parentId', { nullable: true }) parentId?: string,
@Args('sourceEntryIds', { type: () => [String], nullable: true }) sourceEntryIds?: string[],
@Args('position', { type: () => Number, nullable: true }) position?: number
) {
const organizer = await this.dockerOrganizerService.createFolderWithItems({
name,
parentId: parentId ?? DEFAULT_ORGANIZER_ROOT_ID,
sourceEntryIds: sourceEntryIds ?? [],
position,
});
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
@@ -9,10 +9,13 @@ import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/do
import {
addMissingResourcesToView,
createFolderInView,
createFolderWithItems,
DEFAULT_ORGANIZER_ROOT_ID,
DEFAULT_ORGANIZER_VIEW_ID,
deleteOrganizerEntries,
moveEntriesToFolder,
moveItemsToPosition,
renameFolder,
resolveOrganizer,
setFolderChildrenInView,
} from '@app/unraid-api/organizer/organizer.js';
@@ -234,4 +237,99 @@ export class DockerOrganizerService {
this.dockerConfigService.replaceConfig(validated);
return validated;
}
async moveItemsToPosition(params: {
sourceEntryIds: string[];
destinationFolderId: string;
position: number;
}): Promise<OrganizerV1> {
const { sourceEntryIds, destinationFolderId, position } = params;
const organizer = await this.syncAndGetOrganizer();
const newOrganizer = structuredClone(organizer);
const defaultView = newOrganizer.views.default;
if (!defaultView) {
throw new AppError('Default view not found');
}
newOrganizer.views.default = moveItemsToPosition({
view: defaultView,
sourceEntryIds: new Set(sourceEntryIds),
destinationFolderId,
position,
resources: newOrganizer.resources,
});
const validated = await this.dockerConfigService.validate(newOrganizer);
this.dockerConfigService.replaceConfig(validated);
return validated;
}
async renameFolderById(params: { folderId: string; newName: string }): Promise<OrganizerV1> {
const { folderId, newName } = params;
const organizer = await this.syncAndGetOrganizer();
const newOrganizer = structuredClone(organizer);
const defaultView = newOrganizer.views.default;
if (!defaultView) {
throw new AppError('Default view not found');
}
newOrganizer.views.default = renameFolder({
view: defaultView,
folderId,
newName,
});
const validated = await this.dockerConfigService.validate(newOrganizer);
this.dockerConfigService.replaceConfig(validated);
return validated;
}
async createFolderWithItems(params: {
name: string;
parentId?: string;
sourceEntryIds?: string[];
position?: number;
}): Promise<OrganizerV1> {
const { name, parentId = DEFAULT_ORGANIZER_ROOT_ID, sourceEntryIds = [], position } = params;
if (name === DEFAULT_ORGANIZER_ROOT_ID) {
throw new AppError(`Folder name '${name}' is reserved`);
} else if (name === parentId) {
throw new AppError(`Folder ID '${name}' cannot be the same as the parent ID`);
} else if (!name) {
throw new AppError(`Folder name cannot be empty`);
}
const organizer = await this.syncAndGetOrganizer();
const defaultView = organizer.views.default;
if (!defaultView) {
throw new AppError('Default view not found');
}
const parentEntry = defaultView.entries[parentId];
if (!parentEntry || parentEntry.type !== 'folder') {
throw new AppError(`Parent '${parentId}' not found or is not a folder`);
}
if (parentEntry.children.includes(name)) {
return organizer;
}
const newOrganizer = structuredClone(organizer);
newOrganizer.views.default = createFolderWithItems({
view: defaultView,
folderId: name,
folderName: name,
parentId,
sourceEntryIds,
position,
resources: newOrganizer.resources,
});
const validated = await this.dockerConfigService.validate(newOrganizer);
this.dockerConfigService.replaceConfig(validated);
return validated;
}
}
@@ -226,6 +226,12 @@ export class ResolvedOrganizerView {
@ValidateNested()
root!: ResolvedOrganizerEntryType;
@Field(() => [FlatOrganizerEntry])
@IsArray()
@ValidateNested({ each: true })
@Type(() => FlatOrganizerEntry)
flatEntries!: FlatOrganizerEntry[];
@Field(() => GraphQLJSON, { nullable: true })
@IsOptional()
@IsObject()
@@ -246,3 +252,54 @@ export class ResolvedOrganizerV1 {
@Type(() => ResolvedOrganizerView)
views!: ResolvedOrganizerView[];
}
// ============================================
// FLAT ORGANIZER ENTRY (for efficient frontend consumption)
// ============================================
@ObjectType()
export class FlatOrganizerEntry {
@Field()
@IsString()
id!: string;
@Field()
@IsString()
type!: string;
@Field()
@IsString()
name!: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
parentId?: string;
@Field()
@IsNumber()
depth!: number;
@Field()
@IsNumber()
position!: number;
@Field(() => [String])
@IsArray()
@IsString({ each: true })
path!: string[];
@Field()
hasChildren!: boolean;
@Field(() => [String])
@IsArray()
@IsString({ each: true })
childrenIds!: string[];
@Field(() => DockerContainer, { nullable: true })
@IsOptional()
@ValidateNested()
@Type(() => DockerContainer)
meta?: DockerContainer;
}
+165
View File
@@ -1,5 +1,6 @@
import {
AnyOrganizerResource,
FlatOrganizerEntry,
OrganizerFolder,
OrganizerResource,
OrganizerResourceRef,
@@ -143,11 +144,13 @@ export function resolveOrganizerView(
resources: OrganizerV1['resources']
): ResolvedOrganizerView {
const resolvedRoot = resolveEntry(view.root, view, resources);
const flatEntries = flattenResolvedView(resolvedRoot);
return {
id: view.id,
name: view.name,
root: resolvedRoot,
flatEntries,
prefs: view.prefs,
};
}
@@ -172,6 +175,63 @@ export function resolveOrganizer(organizer: OrganizerV1): ResolvedOrganizerV1 {
};
}
/**
* Flattens a resolved organizer entry into a list of flat entries with metadata.
* Each entry includes parent references, depth, position, and path information.
*
* @param resolved - The resolved organizer entry to flatten
* @param parentId - The parent entry ID (optional)
* @param depth - The current depth in the tree (default: 0)
* @param path - The path from root to this entry (default: [])
* @param position - The position in parent's children array (default: 0)
* @returns Array of flat organizer entries with enriched metadata
*/
export function flattenResolvedView(
resolved: ResolvedOrganizerEntryType,
parentId?: string,
depth = 0,
path: string[] = [],
position = 0
): FlatOrganizerEntry[] {
const entries: FlatOrganizerEntry[] = [];
function walk(
entry: ResolvedOrganizerEntryType,
parent: string | undefined,
d: number,
p: string[],
pos: number
): void {
const currentPath = [...p, entry.id];
const isFolder = entry.type === 'folder';
const children = isFolder ? (entry as ResolvedOrganizerFolder).children : [];
const flat: FlatOrganizerEntry = {
id: entry.id,
type: entry.type,
name: entry.name,
parentId: parent,
depth: d,
path: currentPath,
position: pos,
hasChildren: isFolder && children.length > 0,
childrenIds: children.map((c) => c.id),
meta: entry.type === 'container' ? (entry as any).meta : undefined,
};
entries.push(flat);
if (isFolder) {
children.forEach((child, idx) => {
walk(child, entry.id, d + 1, currentPath, idx);
});
}
}
walk(resolved, parentId, depth, path, position);
return entries;
}
export interface CreateFolderInViewParams {
view: OrganizerView;
folderId: string;
@@ -589,3 +649,108 @@ export function moveEntriesToFolder(params: MoveEntriesToFolderParams): Organize
destinationFolder.children = Array.from(destinationChildren);
return newView;
}
export interface MoveItemsToPositionParams {
view: OrganizerView;
sourceEntryIds: Set<string>;
destinationFolderId: string;
position: number;
resources?: OrganizerV1['resources'];
}
/**
* Moves entries to a specific position within a destination folder.
* Combines moveEntriesToFolder with position-based insertion.
*/
export function moveItemsToPosition(params: MoveItemsToPositionParams): OrganizerView {
const { view, sourceEntryIds, destinationFolderId, position, resources } = params;
const movedView = moveEntriesToFolder({ view, sourceEntryIds, destinationFolderId });
const folder = movedView.entries[destinationFolderId] as OrganizerFolder;
const movedIds = Array.from(sourceEntryIds);
const otherChildren = folder.children.filter((id) => !sourceEntryIds.has(id));
const insertPos = Math.max(0, Math.min(position, otherChildren.length));
const reordered = [
...otherChildren.slice(0, insertPos),
...movedIds,
...otherChildren.slice(insertPos),
];
folder.children = reordered;
return movedView;
}
export interface RenameFolderParams {
view: OrganizerView;
folderId: string;
newName: string;
}
/**
* Renames a folder by updating its name property.
* This is simpler than the current create+delete approach.
*/
export function renameFolder(params: RenameFolderParams): OrganizerView {
const { view, folderId, newName } = params;
const newView = structuredClone(view);
const entry = newView.entries[folderId];
if (!entry) {
throw new Error(`Folder with id '${folderId}' not found`);
}
if (entry.type !== 'folder') {
throw new Error(`Entry '${folderId}' is not a folder`);
}
(entry as OrganizerFolder).name = newName;
return newView;
}
export interface CreateFolderWithItemsParams {
view: OrganizerView;
folderId: string;
folderName: string;
parentId: string;
sourceEntryIds?: string[];
position?: number;
resources?: OrganizerV1['resources'];
}
/**
* Creates a new folder and optionally moves items into it at a specific position.
* Combines createFolder + moveItems + positioning in a single atomic operation.
*/
export function createFolderWithItems(params: CreateFolderWithItemsParams): OrganizerView {
const { view, folderId, folderName, parentId, sourceEntryIds = [], position, resources } = params;
let newView = createFolderInView({
view,
folderId,
folderName,
parentId,
childrenIds: sourceEntryIds,
});
if (sourceEntryIds.length > 0) {
newView = moveEntriesToFolder({
view: newView,
sourceEntryIds: new Set(sourceEntryIds),
destinationFolderId: folderId,
});
}
if (position !== undefined) {
const parent = newView.entries[parentId] as OrganizerFolder;
const withoutNewFolder = parent.children.filter((id) => id !== folderId);
const insertPos = Math.max(0, Math.min(position, withoutNewFolder.length));
parent.children = [
...withoutNewFolder.slice(0, insertPos),
folderId,
...withoutNewFolder.slice(insertPos),
];
}
return newView;
}
@@ -178,6 +178,9 @@ const organizerRoot = computed(
() => result.value?.docker?.organizer?.views?.[0]?.root as ResolvedOrganizerFolder | undefined
);
const flatEntries = computed(() => result.value?.docker?.organizer?.views?.[0]?.flatEntries || []);
const rootFolderId = computed(() => result.value?.docker?.organizer?.views?.[0]?.root?.id || 'root');
const containers = computed<DockerContainer[]>(() => result.value?.docker?.containers || []);
function findContainerResourceById(
@@ -279,7 +282,8 @@ const isDetailsDisabled = computed(() => props.disabled || isSwitching.value);
</div>
<DockerContainersTable
:containers="containers"
:organizer-root="organizerRoot"
:flat-entries="flatEntries"
:root-folder-id="rootFolderId"
:loading="loading"
:active-id="activeId"
:selected-ids="selectedIds"
@@ -32,6 +32,8 @@ const containers = computed<DockerContainer[]>(() => []);
const organizerRoot = computed(
() => result.value?.docker?.organizer?.views?.[0]?.root as ResolvedOrganizerFolder | undefined
);
const flatEntries = computed(() => result.value?.docker?.organizer?.views?.[0]?.flatEntries || []);
const rootFolderId = computed(() => result.value?.docker?.organizer?.views?.[0]?.root?.id || 'root');
const handleRefresh = async () => {
await refetch({ skipCache: true });
@@ -52,7 +54,8 @@ const handleRefresh = async () => {
<DockerContainersTable
:containers="containers"
:organizer-root="organizerRoot"
:flat-entries="flatEntries"
:root-folder-id="rootFolderId"
:loading="loading"
@created-folder="handleRefresh"
/>
@@ -5,8 +5,10 @@ import { useMutation } from '@vue/apollo-composable';
import BaseTreeTable from '@/components/Common/BaseTreeTable.vue';
import { GET_DOCKER_CONTAINERS } from '@/components/Docker/docker-containers.query';
import { CREATE_DOCKER_FOLDER } from '@/components/Docker/docker-create-folder.mutation';
import { CREATE_DOCKER_FOLDER_WITH_ITEMS } from '@/components/Docker/docker-create-folder-with-items.mutation';
import { DELETE_DOCKER_ENTRIES } from '@/components/Docker/docker-delete-entries.mutation';
import { MOVE_DOCKER_ENTRIES_TO_FOLDER } from '@/components/Docker/docker-move-entries.mutation';
import { MOVE_DOCKER_ITEMS_TO_POSITION } from '@/components/Docker/docker-move-items-to-position.mutation';
import { PAUSE_DOCKER_CONTAINER } from '@/components/Docker/docker-pause-container.mutation';
import { SET_DOCKER_FOLDER_CHILDREN } from '@/components/Docker/docker-set-folder-children.mutation';
import { START_DOCKER_CONTAINER } from '@/components/Docker/docker-start-container.mutation';
@@ -20,8 +22,7 @@ import { useTreeData } from '@/composables/useTreeData';
import type {
DockerContainer,
ResolvedOrganizerEntry,
ResolvedOrganizerFolder,
FlatOrganizerEntry,
} from '@/composables/gql/graphql';
import type { DropEvent } from '@/composables/useDragDrop';
import type { TreeRow } from '@/composables/useTreeData';
@@ -30,7 +31,8 @@ import type { Component } from 'vue';
interface Props {
containers: DockerContainer[];
organizerRoot?: ResolvedOrganizerFolder;
flatEntries?: FlatOrganizerEntry[];
rootFolderId?: string;
loading?: boolean;
compact?: boolean;
activeId?: string | null;
@@ -42,6 +44,7 @@ const props = withDefaults(defineProps<Props>(), {
compact: false,
activeId: null,
selectedIds: () => [],
rootFolderId: 'root',
});
const UButton = resolveComponent('UButton');
@@ -107,49 +110,21 @@ function toContainerTreeRow(
};
}
function buildDockerTreeRow(entry: ResolvedOrganizerEntry): TreeRow<DockerContainer> | null {
if (entry.__typename === 'OrganizerContainerResource') {
const meta = entry.meta as DockerContainer | null | undefined;
const row = toContainerTreeRow(meta, entry.name || undefined);
row.id = entry.id;
row.containerId = meta?.id;
return row;
}
return {
id: entry.id as string,
type: 'container',
name: (entry as unknown as { name?: string }).name || 'Unknown',
state: '',
ports: '',
autoStart: 'Off',
updates: '—',
};
}
const organizerRootRef = computed(() => {
if (!props.organizerRoot) return undefined;
return {
id: props.organizerRoot.id,
name: props.organizerRoot.name,
children: props.organizerRoot.children,
};
});
const flatEntriesRef = computed(() => props.flatEntries);
const containersRef = computed(() => props.containers);
const { treeData, entryParentById, folderChildrenIds, parentById, getRowById } =
const { treeData, entryParentById, folderChildrenIds, parentById, positionById, getRowById } =
useTreeData<DockerContainer>({
organizerRoot: organizerRootRef,
flatEntries: flatEntriesRef,
flatData: containersRef,
buildTreeRow: (entry) => buildDockerTreeRow(entry as ResolvedOrganizerEntry),
buildFlatRow: toContainerTreeRow,
});
const { visibleFolders, expandedFolders, toggleExpandFolder, setExpandedFolders } = useFolderTree({
organizerRoot: organizerRootRef,
flatEntries: flatEntriesRef,
});
const rootFolderId = computed<string>(() => props.organizerRoot?.id || '');
const rootFolderId = computed<string>(() => props.rootFolderId || 'root');
const busyRowIds = ref<Set<string>>(new Set());
function setRowsBusy(ids: string[], busy: boolean) {
@@ -305,7 +280,9 @@ const columnsMenuItems = computed<DropdownMenuItems>(() => {
});
const { mutate: createFolderMutation, loading: creating } = useMutation(CREATE_DOCKER_FOLDER);
const { mutate: createFolderWithItemsMutation } = useMutation(CREATE_DOCKER_FOLDER_WITH_ITEMS);
const { mutate: moveEntriesMutation, loading: moving } = useMutation(MOVE_DOCKER_ENTRIES_TO_FOLDER);
const { mutate: moveItemsToPositionMutation } = useMutation(MOVE_DOCKER_ITEMS_TO_POSITION);
const { mutate: deleteEntriesMutation, loading: deleting } = useMutation(DELETE_DOCKER_ENTRIES);
const { mutate: setFolderChildrenMutation } = useMutation(SET_DOCKER_FOLDER_CHILDREN);
const { mutate: startContainerMutation } = useMutation(START_DOCKER_CONTAINER);
@@ -363,72 +340,6 @@ const confirmToStart = computed(() => containerActions.confirmToStart.value || [
const confirmToPause = computed(() => containerActions.confirmToPause.value || []);
const confirmToResume = computed(() => containerActions.confirmToResume.value || []);
function getFolderChildrenList(folderId: string): string[] {
return [...(folderChildrenIds.value[folderId] || [])];
}
function computeInsertIndex(
children: string[],
targetId: string,
area: 'before' | 'after' | 'inside'
): number {
const idx = Math.max(0, children.indexOf(targetId));
return area === 'before' ? idx : idx + 1;
}
async function reorderWithinFolder(
folderId: string,
movingIds: string[],
targetId: string,
area: 'before' | 'after' | 'inside'
) {
const current = getFolderChildrenList(folderId);
const removeSet = new Set(movingIds);
const filtered = current.filter((id) => !removeSet.has(id));
const insertIndex = computeInsertIndex(filtered, targetId, area);
const finalIds = [
...filtered.slice(0, insertIndex),
...movingIds.filter((id) => id !== targetId),
...filtered.slice(insertIndex),
];
await setFolderChildrenMutation(
{ folderId, childrenIds: finalIds },
{
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
awaitRefetchQueries: true,
}
);
}
async function moveAcrossFoldersWithPosition(
destinationFolderId: string,
movingIds: string[],
targetId: string,
area: 'before' | 'after' | 'inside'
) {
await moveEntriesMutation(
{ destinationFolderId, sourceEntryIds: movingIds },
{
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
awaitRefetchQueries: true,
}
);
const current = getFolderChildrenList(destinationFolderId).filter((id) => !movingIds.includes(id));
const insertIndex = computeInsertIndex(current, targetId, area);
const finalIds = [
...current.slice(0, insertIndex),
...movingIds.filter((id) => id !== targetId),
...current.slice(insertIndex),
];
await setFolderChildrenMutation(
{ folderId: destinationFolderId, childrenIds: finalIds },
{
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
awaitRefetchQueries: true,
}
);
}
async function moveIntoFolder(destinationFolderId: string, movingIds: string[]) {
await moveEntriesMutation(
{ destinationFolderId, sourceEntryIds: movingIds },
@@ -441,33 +352,18 @@ async function moveIntoFolder(destinationFolderId: string, movingIds: string[])
async function createFolderFromDrop(containerEntryId: string, movingIds: string[]) {
const parentId = entryParentById.value[containerEntryId] || rootFolderId.value;
const parentChildren = getFolderChildrenList(parentId);
const targetIndex = parentChildren.indexOf(containerEntryId);
const targetPosition = positionById.value[containerEntryId] ?? 0;
const name = window.prompt('New folder name?')?.trim();
if (!name) return;
await createFolderMutation(
{ name, parentId, childrenIds: [] },
{
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
awaitRefetchQueries: true,
}
);
const toMove = [containerEntryId, ...movingIds.filter((id) => id !== containerEntryId)];
await moveEntriesMutation(
{ destinationFolderId: name, sourceEntryIds: toMove },
{
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
awaitRefetchQueries: true,
}
);
const updated = getFolderChildrenList(parentId).filter((id) => !toMove.includes(id));
const final = [
...updated.slice(0, Math.max(0, targetIndex)),
name,
...updated.slice(Math.max(0, targetIndex)),
];
await setFolderChildrenMutation(
{ folderId: parentId, childrenIds: final },
await createFolderWithItemsMutation(
{
name,
parentId,
sourceEntryIds: toMove,
position: targetPosition
},
{
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
awaitRefetchQueries: true,
@@ -477,7 +373,7 @@ async function createFolderFromDrop(containerEntryId: string, movingIds: string[
}
async function handleDropOnRow(event: DropEvent<DockerContainer>) {
if (!props.organizerRoot) return;
if (!props.flatEntries) return;
const { target, area, sourceIds: movingIds } = event;
if (!movingIds.length) return;
@@ -491,15 +387,22 @@ async function handleDropOnRow(event: DropEvent<DockerContainer>) {
await createFolderFromDrop(target.id, movingIds);
return;
}
const parentId = entryParentById.value[target.id] || rootFolderId.value;
const sameParent = movingIds.every(
(id) => (entryParentById.value[id] || rootFolderId.value) === parentId
const targetPosition = positionById.value[target.id] ?? 0;
const position = area === 'before' ? targetPosition : targetPosition + 1;
await moveItemsToPositionMutation(
{
sourceEntryIds: movingIds,
destinationFolderId: parentId,
position
},
{
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
awaitRefetchQueries: true,
}
);
if (sameParent) {
await reorderWithinFolder(parentId, movingIds, target.id, area);
} else {
await moveAcrossFoldersWithPosition(parentId, movingIds, target.id, area);
}
}
function getSelectedEntryIds(): string[] {
@@ -623,7 +526,7 @@ function getRowActionItems(row: TreeRow<DockerContainer>): DropdownMenuItems {
:active-id="activeId"
:selected-ids="selectedIds"
:busy-row-ids="busyRowIds"
:enable-drag-drop="!!organizerRoot"
:enable-drag-drop="!!flatEntries"
@row:click="
(payload) =>
emit('row:click', {
@@ -15,126 +15,36 @@ export const GET_DOCKER_CONTAINERS = gql`
id
name
type
children {
__typename
... on ResolvedOrganizerFolder {
id
name
type
children {
__typename
... on ResolvedOrganizerFolder {
id
name
type
children {
__typename
... on ResolvedOrganizerFolder {
id
name
type
}
... on OrganizerContainerResource {
id
name
type
meta {
id
names
state
status
image
ports {
privatePort
publicPort
type
}
autoStart
hostConfig {
networkMode
}
created
isUpdateAvailable
isRebuildReady
}
}
}
}
... on OrganizerContainerResource {
id
name
type
meta {
id
names
state
status
image
ports {
privatePort
publicPort
type
}
autoStart
hostConfig {
networkMode
}
created
isUpdateAvailable
isRebuildReady
}
}
}
}
... on OrganizerContainerResource {
id
name
type
meta {
id
names
state
status
image
ports {
privatePort
publicPort
type
}
autoStart
hostConfig {
networkMode
}
created
isUpdateAvailable
isRebuildReady
}
}
}
}
... on OrganizerContainerResource {
}
flatEntries {
id
type
name
parentId
depth
position
path
hasChildren
childrenIds
meta {
id
name
type
meta {
id
names
state
status
image
ports {
privatePort
publicPort
type
}
autoStart
hostConfig {
networkMode
}
created
isUpdateAvailable
isRebuildReady
names
state
status
image
ports {
privatePort
publicPort
type
}
autoStart
hostConfig {
networkMode
}
created
isUpdateAvailable
isRebuildReady
}
}
}
@@ -0,0 +1,62 @@
import { gql } from '@apollo/client';
export const CREATE_DOCKER_FOLDER_WITH_ITEMS = gql`
mutation CreateDockerFolderWithItems(
$name: String!
$parentId: String
$sourceEntryIds: [String!]
$position: Float
) {
createDockerFolderWithItems(
name: $name
parentId: $parentId
sourceEntryIds: $sourceEntryIds
position: $position
) {
version
views {
id
name
root {
__typename
... on ResolvedOrganizerFolder {
id
name
type
}
}
flatEntries {
id
type
name
parentId
depth
position
path
hasChildren
childrenIds
meta {
id
names
state
status
image
ports {
privatePort
publicPort
type
}
autoStart
hostConfig {
networkMode
}
created
isUpdateAvailable
isRebuildReady
}
}
}
}
}
`;
@@ -0,0 +1,60 @@
import { gql } from '@apollo/client';
export const MOVE_DOCKER_ITEMS_TO_POSITION = gql`
mutation MoveDockerItemsToPosition(
$sourceEntryIds: [String!]!
$destinationFolderId: String!
$position: Float!
) {
moveDockerItemsToPosition(
sourceEntryIds: $sourceEntryIds
destinationFolderId: $destinationFolderId
position: $position
) {
version
views {
id
name
root {
__typename
... on ResolvedOrganizerFolder {
id
name
type
}
}
flatEntries {
id
type
name
parentId
depth
position
path
hasChildren
childrenIds
meta {
id
names
state
status
image
ports {
privatePort
publicPort
type
}
autoStart
hostConfig {
networkMode
}
created
isUpdateAvailable
isRebuildReady
}
}
}
}
}
`;
@@ -0,0 +1,52 @@
import { gql } from '@apollo/client';
export const RENAME_DOCKER_FOLDER = gql`
mutation RenameDockerFolder($folderId: String!, $newName: String!) {
renameDockerFolder(folderId: $folderId, newName: $newName) {
version
views {
id
name
root {
__typename
... on ResolvedOrganizerFolder {
id
name
type
}
}
flatEntries {
id
type
name
parentId
depth
position
path
hasChildren
childrenIds
meta {
id
names
state
status
image
ports {
privatePort
publicPort
type
}
autoStart
hostConfig {
networkMode
}
created
isUpdateAvailable
isRebuildReady
}
}
}
}
}
`;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,2 +1,2 @@
export * from './fragment-masking';
export * from './gql';
export * from "./fragment-masking";
export * from "./gql";
+28 -49
View File
@@ -1,14 +1,8 @@
import { computed, ref, unref } from 'vue';
import type { OrganizerEntry } from '@/composables/useTreeData';
import type { FlatOrganizerEntry } from '@/composables/gql/graphql';
import type { MaybeRef } from 'vue';
export interface FolderNode {
id: string;
name: string;
children: FolderNode[];
}
export interface FlatFolderRow {
id: string;
name: string;
@@ -17,61 +11,47 @@ export interface FlatFolderRow {
}
export interface FolderTreeOptions {
organizerRoot?: MaybeRef<{ id: string; name?: string; children?: OrganizerEntry[] } | undefined>;
flatEntries?: MaybeRef<FlatOrganizerEntry[] | undefined>;
}
export function useFolderTree(options: FolderTreeOptions) {
const { organizerRoot } = options;
const { flatEntries } = options;
const expandedFolders = ref<Set<string>>(new Set());
function buildFolderOnlyTree(
entry?: { id: string; name?: string; children?: OrganizerEntry[] } | null
): FolderNode | null {
if (!entry) return null;
const folders: FolderNode[] = [];
for (const child of entry.children || []) {
if ((child as OrganizerEntry).__typename?.includes('Folder')) {
const sub = buildFolderOnlyTree(
child as { id: string; name?: string; children?: OrganizerEntry[] }
);
if (sub) folders.push(sub);
}
}
return { id: entry.id, name: entry.name || 'Unnamed', children: folders };
}
const folderTree = computed<FolderNode | null>(() => {
return buildFolderOnlyTree(unref(organizerRoot));
const allFolders = computed<FlatOrganizerEntry[]>(() => {
const entries = unref(flatEntries);
if (!entries) return [];
return entries.filter((e) => e.type === 'folder');
});
function flattenVisibleFolders(
node: FolderNode | null,
depth = 0,
out: FlatFolderRow[] = []
): FlatFolderRow[] {
if (!node) return out;
const visibleFolders = computed<FlatFolderRow[]>(() => {
const folders = allFolders.value;
const visible: FlatFolderRow[] = [];
const expanded = expandedFolders.value;
out.push({
id: node.id,
name: node.name,
depth,
hasChildren: node.children.length > 0,
});
const visibleIds = new Set<string>();
if (expandedFolders.value.has(node.id)) {
for (const child of node.children) {
flattenVisibleFolders(child, depth + 1, out);
for (const folder of folders) {
if (!folder.parentId) {
visibleIds.add(folder.id);
} else if (visibleIds.has(folder.parentId) && expanded.has(folder.parentId)) {
visibleIds.add(folder.id);
}
}
return out;
}
for (const folder of folders) {
if (visibleIds.has(folder.id)) {
visible.push({
id: folder.id,
name: folder.name,
depth: folder.depth,
hasChildren: folder.hasChildren,
});
}
}
const visibleFolders = computed<FlatFolderRow[]>(() => {
return flattenVisibleFolders(folderTree.value);
return visible;
});
function toggleExpandFolder(id: string) {
@@ -94,7 +74,6 @@ export function useFolderTree(options: FolderTreeOptions) {
}
return {
folderTree,
visibleFolders,
expandedFolders,
toggleExpandFolder,
+60 -82
View File
@@ -1,5 +1,6 @@
import { computed, unref } from 'vue';
import type { FlatOrganizerEntry } from '@/composables/gql/graphql';
import type { MaybeRef } from 'vue';
export interface TreeRow<T = unknown> {
@@ -15,112 +16,88 @@ export interface TreeRow<T = unknown> {
containerId?: string;
}
export interface OrganizerEntry {
__typename?: string;
id: string;
name?: string;
children?: OrganizerEntry[];
meta?: unknown;
type?: string;
}
export interface TreeDataOptions<T> {
organizerRoot?: MaybeRef<{ id: string; children?: OrganizerEntry[] } | undefined>;
flatEntries?: MaybeRef<FlatOrganizerEntry[] | undefined>;
flatData?: MaybeRef<T[]>;
buildTreeRow: (entry: OrganizerEntry) => TreeRow<T> | null;
buildFlatRow?: (item: T) => TreeRow<T>;
}
export function useTreeData<T = unknown>(options: TreeDataOptions<T>) {
const { organizerRoot, flatData, buildTreeRow, buildFlatRow } = options;
function buildTree(entry: OrganizerEntry): TreeRow<T> | null {
if (entry.__typename?.includes('Folder')) {
const children = (entry.children || [])
.map((child) => buildTree(child))
.filter(Boolean) as TreeRow<T>[];
return {
id: entry.id,
type: 'folder',
name: entry.name || 'Unnamed',
children,
};
}
return buildTreeRow(entry);
}
const { flatEntries, flatData, buildFlatRow } = options;
const treeData = computed<TreeRow<T>[]>(() => {
const root = unref(organizerRoot);
const flat = unref(flatData);
const flat = unref(flatEntries);
const fallbackFlat = unref(flatData);
if (root) {
return (root.children || []).map((child) => buildTree(child)).filter(Boolean) as TreeRow<T>[];
if (flat && flat.length > 0) {
const entriesById = new Map(flat.map((e) => [e.id, e]));
const rootEntries: TreeRow<T>[] = [];
function buildTreeFromFlat(entry: FlatOrganizerEntry): TreeRow<T> {
const row: TreeRow<T> = {
id: entry.id,
type: entry.type,
name: entry.name,
meta: entry.meta as T,
children: [],
};
if (entry.hasChildren) {
row.children = entry.childrenIds
.map((childId) => entriesById.get(childId))
.filter(Boolean)
.map((child) => buildTreeFromFlat(child!));
}
return row;
}
for (const entry of flat) {
if (!entry.parentId) {
rootEntries.push(buildTreeFromFlat(entry));
}
}
return rootEntries;
}
if (flat && buildFlatRow) {
return flat.map(buildFlatRow);
if (fallbackFlat && buildFlatRow) {
return fallbackFlat.map(buildFlatRow);
}
return [];
});
const entryParentById = computed<Record<string, string>>(() => {
const map: Record<string, string> = {};
const root = unref(organizerRoot);
function walk(node?: { id: string; children?: OrganizerEntry[] } | null) {
if (!node) return;
for (const child of node.children || []) {
const id = (child as { id?: string }).id;
if (id) map[id] = node.id;
if ((child as OrganizerEntry).__typename?.includes('Folder')) {
walk(child as { id: string; children?: OrganizerEntry[] });
}
}
}
walk(root);
return map;
const entries = unref(flatEntries);
if (!entries) return {};
return Object.fromEntries(
entries.filter((e) => e.parentId).map((e) => [e.id, e.parentId!])
);
});
const folderChildrenIds = computed<Record<string, string[]>>(() => {
const map: Record<string, string[]> = {};
const root = unref(organizerRoot);
function walk(node?: { id: string; children?: OrganizerEntry[] } | null) {
if (!node) return;
map[node.id] = (node.children || []).map((c) => (c as { id: string }).id);
for (const child of node.children || []) {
if ((child as OrganizerEntry).__typename?.includes('Folder')) {
walk(child as { id: string; children?: OrganizerEntry[] });
}
}
}
walk(root);
return map;
const entries = unref(flatEntries);
if (!entries) return {};
return Object.fromEntries(
entries.filter((e) => e.type === 'folder').map((e) => [e.id, e.childrenIds])
);
});
const parentById = computed<Record<string, string>>(() => {
const map: Record<string, string> = {};
const root = unref(organizerRoot);
const entries = unref(flatEntries);
if (!entries) return {};
return Object.fromEntries(
entries
.filter((e) => e.type === 'folder' && e.parentId)
.map((e) => [e.id, e.parentId!])
);
});
function walk(node?: { id: string; children?: OrganizerEntry[] } | null, parentId?: string) {
if (!node) return;
if (parentId) map[node.id] = parentId;
for (const child of node.children || []) {
if ((child as OrganizerEntry).__typename?.includes('Folder')) {
walk(child as { id: string; children?: OrganizerEntry[] }, node.id);
}
}
}
walk(root, undefined);
return map;
const positionById = computed<Record<string, number>>(() => {
const entries = unref(flatEntries);
if (!entries) return {};
return Object.fromEntries(entries.map((e) => [e.id, e.position]));
});
function flattenRows(rows: TreeRow<T>[], filterType?: string): TreeRow<T>[] {
@@ -150,6 +127,7 @@ export function useTreeData<T = unknown>(options: TreeDataOptions<T>) {
entryParentById,
folderChildrenIds,
parentById,
positionById,
flattenRows,
getRowById,
};