mirror of
https://github.com/Alfresco/alfresco-content-app.git
synced 2026-09-09 18:02:54 +00:00
[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:
co-authored by
Adam Świderski
parent
ff7560b7fc
commit
c4f3e988c8
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user