mirror of
https://github.com/unraid/api.git
synced 2026-05-13 11:29:25 -05:00
refactor: simplify organizer operations
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
export * from './fragment-masking';
|
||||
export * from './gql';
|
||||
export * from "./fragment-masking";
|
||||
export * from "./gql";
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user