[ACS-12333][ACS-12335][ACS-12334] Add Checkout / Check-in Support (#5360)

Co-authored-by: Adam Świderski <adam.tomasz.swiderski@gmail.com>
This commit is contained in:
Mykyta Maliarchuk
2026-09-08 10:57:16 +02:00
committed by GitHub
co-authored by Adam Świderski
parent ff7560b7fc
commit c4f3e988c8
40 changed files with 1271 additions and 311 deletions
+165 -2
View File
@@ -25,7 +25,7 @@
import * as app from './app.rules';
import { createVersionRule, getFileExtension, isPreferencesApiAvailable, isNodeInfoAvailable, isBulkActionsAvailable } from './app.rules';
import { TestRuleContext } from './test-rule-context';
import { NodeEntry, RepositoryInfo, StatusInfo } from '@alfresco/js-api';
import { NodeEntry, RepositoryInfo, SharedLink, StatusInfo } from '@alfresco/js-api';
import { ProfileState, RuleContext } from '@alfresco/adf-extensions';
describe('app.evaluators', () => {
@@ -1114,7 +1114,9 @@ describe('app.evaluators', () => {
});
it('should return true when file is locked and user is the owner of the lock', () => {
context.selection.file = { entry: { properties: { 'cm:lockType': 'WRITE_LOCK', 'cm:lockOwner': { id: 'test1' } } } } as NodeEntry;
const file = { entry: { isFile: true, properties: { 'cm:lockType': 'WRITE_LOCK', 'cm:lockOwner': { id: 'test1' } } } } as NodeEntry;
context.selection.file = file;
context.selection.first = file;
context.profile.id = 'test1';
context.permissions = { check: () => false };
expect(app.canToggleFileLock(context)).toBeTrue();
@@ -1206,6 +1208,42 @@ describe('app.evaluators', () => {
});
});
describe('isLockedOrWorkingCopy', () => {
it('should return false when there is no entry', () => {
expect(app.isLockedOrWorkingCopy(context, { entry: null })).toBeFalse();
});
it('should return false for a folder (not a file and no nodeId)', () => {
expect(app.isLockedOrWorkingCopy(context, { entry: { isFolder: true, isFile: false } } as NodeEntry)).toBeFalse();
});
it('should return true for a locked file', () => {
expect(app.isLockedOrWorkingCopy(context, { entry: { isFile: true, isLocked: true, aspectNames: [] } } as NodeEntry)).toBeTrue();
});
it('should return true for a file with cm:checkedOut aspect', () => {
expect(
app.isLockedOrWorkingCopy(context, { entry: { isFile: true, isLocked: false, aspectNames: ['cm:checkedOut'] } } as NodeEntry)
).toBeTrue();
});
it('should return true for a file with cm:workingcopy aspect', () => {
expect(
app.isLockedOrWorkingCopy(context, { entry: { isFile: true, isLocked: false, aspectNames: ['cm:workingcopy'] } } as NodeEntry)
).toBeTrue();
});
it('should return true for a shared link (no isFile) with cm:checkedOut aspect - ensures lock badge shows on shared page', () => {
const sharedLinkEntry = { nodeId: 'node-1', isFile: undefined, isFolder: undefined, aspectNames: ['cm:checkedOut'] } as SharedLink;
expect(app.isLockedOrWorkingCopy(context, { entry: sharedLinkEntry } as NodeEntry)).toBeTrue();
});
it('should return false for a shared link (no isFile) without lock aspects', () => {
const sharedLinkEntry = { nodeId: 'node-1', isFile: undefined, isFolder: undefined, aspectNames: [] } as SharedLink;
expect(app.isLockedOrWorkingCopy(context, { entry: sharedLinkEntry } as NodeEntry)).toBeFalse();
});
});
describe('isCheckedOut', () => {
it('should return false when there is no selection', () => {
context.selection.isEmpty = true;
@@ -1225,6 +1263,131 @@ describe('app.evaluators', () => {
});
});
describe('isWorkingCopy', () => {
it('should return false when selection is empty', () => {
context.selection.isEmpty = true;
expect(app.isWorkingCopy(context)).toBeFalse();
});
it('should return false when node does not have cm:workingcopy aspect', () => {
context.selection.isEmpty = false;
context.selection.first = { entry: { aspectNames: ['cm:checkedOut'] } } as NodeEntry;
expect(app.isWorkingCopy(context)).toBeFalse();
});
it('should return true when node has cm:workingcopy aspect', () => {
context.selection.isEmpty = false;
context.selection.first = { entry: { aspectNames: ['cm:workingcopy'] } } as NodeEntry;
expect(app.isWorkingCopy(context)).toBeTrue();
});
});
describe('canCheckout', () => {
beforeEach(() => {
context.selection.isEmpty = false;
context.selection.file = { entry: { isFile: true, aspectNames: [], properties: {} } } as NodeEntry;
context.selection.first = context.selection.file;
context.selection.nodes = [context.selection.file] as NodeEntry[];
context.permissions = { check: () => true };
});
it('should return false when no file is selected', () => {
context.selection.file = null;
expect(app.canCheckout(context)).toBeFalse();
});
it('should return false when in trashcan', () => {
context.navigation = { url: '/trashcan' };
expect(app.canCheckout(context)).toBeFalse();
});
it('should return false when selected node is a link', () => {
context.selection.nodes = [{ entry: { nodeType: 'app:filelink' } }] as NodeEntry[];
expect(app.canCheckout(context)).toBeFalse();
});
it('should return false when user has no update permission', () => {
context.permissions = { check: () => false };
expect(app.canCheckout(context)).toBeFalse();
});
it('should return false when node is already checked out', () => {
context.selection.first = {
entry: { isFile: true, aspectNames: ['cm:checkedOut'], properties: { 'cm:lockType': 'READ_ONLY_LOCK' } }
} as NodeEntry;
context.selection.file = context.selection.first;
context.selection.nodes = [context.selection.first] as NodeEntry[];
expect(app.canCheckout(context)).toBeFalse();
});
it('should return false when node is a working copy', () => {
context.selection.first = { entry: { isFile: true, aspectNames: ['cm:workingcopy'], properties: {} } } as NodeEntry;
context.selection.file = context.selection.first;
context.selection.nodes = [context.selection.first] as NodeEntry[];
expect(app.canCheckout(context)).toBeFalse();
});
it('should return true for a plain unlocked file with update permission', () => {
expect(app.canCheckout(context)).toBeTrue();
});
});
describe('canCancelCheckout', () => {
beforeEach(() => {
context.selection.isEmpty = false;
context.selection.file = {
entry: { isFile: true, aspectNames: ['cm:checkedOut'], properties: { 'cm:lockType': 'READ_ONLY_LOCK', 'cm:lockOwner': { id: 'user1' } } }
} as NodeEntry;
context.selection.first = context.selection.file;
context.selection.nodes = [context.selection.file] as NodeEntry[];
context.profile = { id: 'user1' } as unknown as ProfileState;
context.permissions = { check: () => true };
});
it('should return false when no file is selected', () => {
context.selection.file = null;
expect(app.canCancelCheckout(context)).toBeFalse();
});
it('should return false when in trashcan', () => {
context.navigation = { url: '/trashcan' };
expect(app.canCancelCheckout(context)).toBeFalse();
});
it('should return false when selected node is a link', () => {
context.selection.nodes = [{ entry: { nodeType: 'app:filelink' } }] as NodeEntry[];
expect(app.canCancelCheckout(context)).toBeFalse();
});
it('should return false when node is not checked out and not a working copy', () => {
context.selection.first = { entry: { isFile: true, aspectNames: [], properties: {} } } as NodeEntry;
context.selection.file = context.selection.first;
expect(app.canCancelCheckout(context)).toBeFalse();
});
it('should return true when node is checked-out original and user has delete permission', () => {
expect(app.canCancelCheckout(context)).toBeTrue();
});
it('should return true when node is a working copy and user has delete permission', () => {
context.selection.first = { entry: { isFile: true, aspectNames: ['cm:workingcopy'], properties: {} } } as NodeEntry;
context.selection.file = context.selection.first;
context.selection.nodes = [context.selection.first] as NodeEntry[];
expect(app.canCancelCheckout(context)).toBeTrue();
});
it('should return true when user is the lock owner even without delete permission', () => {
context.permissions = { check: () => false };
expect(app.canCancelCheckout(context)).toBeTrue();
});
it('should return false when user is not the lock owner and has no delete permission', () => {
context.profile = { id: 'other-user' } as ProfileState;
context.permissions = { check: () => false };
expect(app.canCancelCheckout(context)).toBeFalse();
});
});
describe('isNodeLink', () => {
it('should return false when selection is empty', () => {
context.selection.isEmpty = true;
+109 -12
View File
@@ -24,6 +24,7 @@
import { AppConfigService } from '@alfresco/adf-core';
import { RuleContext } from '@alfresco/adf-extensions';
import { NodeEntry, SharedLink } from '@alfresco/js-api';
import * as navigation from './navigation.rules';
import * as repository from './repository.rules';
import { isAdmin } from './user.rules';
@@ -121,9 +122,14 @@ export function canRemoveFavorite(context: RuleContext): boolean {
* JSON ref: `app.selection.file.canShare`
*/
export const canShareFile = (context: RuleContext): boolean =>
[context.selection.file, !navigation.isTrashcan(context), repository.hasQuickShareEnabled(context), !isShared(context), !isNodeLink(context)].every(
Boolean
);
[
context.selection.file,
!navigation.isTrashcan(context),
repository.hasQuickShareEnabled(context),
!isShared(context),
!isNodeLink(context),
!isLockedOrWorkingCopy(context)
].every(Boolean);
/**
* Checks if user can perform "Join" or "Cancel Join Request" on a library.
@@ -210,7 +216,7 @@ export function canCreateFolder(context: AcaRuleContext): boolean {
* JSON ref: `app.selection.canDownload`
*/
export function canDownloadSelection(context: RuleContext): boolean {
if (isNodeLink(context)) {
if (context.selection.nodes.some((node) => isLockedOrWorkingCopy(context, node)) || isNodeLink(context)) {
return false;
}
return context.selection.nodes.every((node: any) => node.entry && (node.entry.isFile || node.entry.isFolder || !!node.entry.nodeId));
@@ -320,14 +326,51 @@ export const isWriteLocked = (context: RuleContext): boolean =>
);
/**
* Checks if the selected file has **write** or **read-only** locks specified,
* and that current user is the owner of the lock.
* Checks if a node is locked or a working copy. Accepts an optional node argument
* so it can be evaluated per-row (e.g. for badges), falling back to context.selection.first.
* JSON ref: `app.selection.isLockedOrWorkingCopy`
*
* Covers:
* - `entry.isLocked` — any server-reported lock
* - `cm:lockType` property — present when locked via the Lock action
* - `cm:workingcopy` aspect — the working copy created on checkout
* - `cm:checkedOut` aspect — the original file while its working copy exists
*
* @param context Rule execution context
* @param node Optional node to evaluate; defaults to context.selection.first
*/
export function isLockedOrWorkingCopy(context: RuleContext, node?: NodeEntry): boolean {
const entry = (node ?? context?.selection?.first)?.entry;
if (!entry) {
return false;
}
if (!entry.isFile && !(entry as SharedLink).nodeId) {
return false;
}
return (
entry.isLocked === true ||
!!entry.properties?.['cm:lockType'] ||
(entry.aspectNames ?? []).includes('cm:workingcopy') ||
(entry.aspectNames ?? []).includes('cm:checkedOut')
);
}
/**
* Checks if the current user owns the lock on the selected file.
* Handles both regular write/read-only locks (`cm:lockOwner`) and
* working copies (`cm:workingCopyOwner`), which have no `cm:lockType` themselves.
* JSON ref: `app.selection.file.isLockOwner`
*/
export const isUserWriteLockOwner = (context: RuleContext): boolean =>
isWriteLocked(context) &&
context.selection.file?.entry.properties['cm:lockOwner'] &&
context.selection.file?.entry.properties['cm:lockOwner'].id === context.profile.id;
export const isUserWriteLockOwner = (context: RuleContext): boolean => {
const entry = context.selection.file?.entry;
if (!entry) {
return false;
}
if (entry.aspectNames?.includes('cm:workingcopy')) {
return entry.properties?.['cm:workingCopyOwner']?.id === context.profile.id;
}
return isWriteLocked(context) && !!entry.properties?.['cm:lockOwner'] && entry.properties['cm:lockOwner'].id === context.profile.id;
};
/**
* Checks if user can lock selected file.
@@ -371,6 +414,9 @@ export function canUploadVersion(context: RuleContext): boolean {
* @param context Rule execution context
*/
export const canPrintFile = (context: RuleContext): boolean => {
if (isLockedOrWorkingCopy(context)) {
return false;
}
const nodeEntry = context.selection.file.entry;
if (!nodeEntry?.content?.mimeType) {
return false;
@@ -395,6 +441,7 @@ export const canToggleSharedLink = (context: RuleContext): boolean => [canShareF
*/
export const canEditAspects = (context: RuleContext): boolean =>
[
!isWorkingCopy(context),
!isMultiselection(context),
canUpdateSelectedNode(context),
!isWriteLocked(context),
@@ -402,8 +449,7 @@ export const canEditAspects = (context: RuleContext): boolean =>
repository.isMajorVersionAvailable(context, '7')
].every(Boolean);
export const canToggleFileLock = (context: RuleContext): boolean =>
!isNodeLink(context) && [canLockFile(context) || canUnlockFile(context)].some(Boolean);
export const canToggleFileLock = (context: RuleContext): boolean => canCheckout(context) || canCancelCheckout(context);
/**
* @deprecated Uses workarounds for for recent files and search api issues.
@@ -451,6 +497,10 @@ export function canOpenWithOffice(context: AcaRuleContext): boolean {
return false;
}
if (isWorkingCopy(context)) {
return false;
}
if (context.navigation?.url?.startsWith('/trashcan')) {
return false;
}
@@ -600,6 +650,53 @@ export const isCheckedOut = (context: RuleContext): boolean => {
return false;
};
/**
* Checks if the selected node is a working copy (has cm:workingcopy aspect).
* JSON ref: `app.selection.isWorkingCopy`
*
* @param context Rule execution context
*/
export const isWorkingCopy = (context: RuleContext): boolean => {
if (!context.selection?.isEmpty) {
return (context.selection.first?.entry?.aspectNames ?? []).includes('cm:workingcopy');
}
return false;
};
/**
* Checks if the selected file node can be checked out.
* Returns true for cm:content (or subtype) file nodes that are not already
* checked out, not locked, not a working copy, and where the user has update permission.
* Blocks folders, node links, trashcan items, and special system nodes (no update permission).
* JSON ref: `app.selection.canCheckout`
*
* @param context Rule execution context
*/
export const canCheckout = (context: RuleContext): boolean =>
hasFileSelected(context) &&
!navigation.isTrashcan(context) &&
!isNodeLink(context) &&
canUpdateSelectedNode(context) &&
!isLockedOrWorkingCopy(context);
/**
* Checks if checkout can be cancelled for the selected node.
* Returns true when a checked-out original or working copy is selected
* and the user is the lock owner or has delete permission.
* JSON ref: `app.selection.canCancelCheckout`
*
* @param context Rule execution context
*/
export const canCancelCheckout = (context: RuleContext): boolean => {
return (
hasFileSelected(context) &&
!navigation.isTrashcan(context) &&
!isNodeLink(context) &&
isLockedOrWorkingCopy(context) &&
(context.permissions.check(context.selection.file?.entry, ['delete']) || isUserWriteLockOwner(context))
);
};
/**
* Checks if any of the selected nodes is a link node (app:filelink or app:folderlink).
* JSON ref: `app.selection.isNodeLink`