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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 36 additions & 23 deletions playwright/e2e/workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,17 @@ const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin'

const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

const workflowRuleSelector = '.section.rule, [class^="_rule_"]'

const selectNcOption = async(page: Page, combobox: Locator, optionName: string) => {
await combobox.click()
await page.locator('[role="option"]').filter({
hasText: new RegExp(`^\\s*${escapeRegex(optionName)}\\s*$`),
}).first().click()
}

const ensureFlowCardIsVisible = async(page: Page, addFlowCard: Locator) => {
if (await addFlowCard.count() > 0) {
const ensureFlowCardIsVisible = async(page: Page, addFlowButton: Locator) => {
if (await addFlowButton.count() > 0) {
return
}

Expand All @@ -29,28 +31,28 @@ const ensureFlowCardIsVisible = async(page: Page, addFlowCard: Locator) => {
await showMoreButton.click()
}

await expect(addFlowCard).toBeVisible()
await expect(addFlowButton).toBeVisible()
}

const configureDraftRule = async(page: Page, actionName: string, label: string, fieldValue: string, configureOperation?: (configuredRule: Locator) => Promise<void>, operationValue?: string) => {
const initialRuleCount = await page.locator('.section.rule').count()
const addFlowCard = page.locator('.actions__item.colored').filter({
has: page.getByRole('heading', { name: actionName, exact: true }),
})
await ensureFlowCardIsVisible(page, addFlowCard)
await addFlowCard.getByRole('button', { name: 'Add new flow' }).click()

const configuredRule = page.locator('.section.rule').filter({
const initialRuleCount = await page.locator(workflowRuleSelector).count()
const addFlowButton = page.getByRole('heading', { name: actionName, exact: true })
.locator('..')
.getByRole('button', { name: 'Add new flow' })
await ensureFlowCardIsVisible(page, addFlowButton)
await addFlowButton.click()

const configuredRule = page.locator(workflowRuleSelector).filter({
has: page.getByRole('button', { name: 'Cancel', exact: true }),
}).last()
await expect(configuredRule).toBeVisible()
const configuredRuleIndex = await configuredRule.evaluate((element) => {
return Array.from(document.querySelectorAll('.section.rule')).indexOf(element)
})
const configuredRuleIndex = await configuredRule.evaluate((element, selector) => {
return Array.from(document.querySelectorAll(selector)).indexOf(element)
}, workflowRuleSelector)

await expect(configuredRule.getByText('Profile field value updated', { exact: true })).toBeVisible()
await selectNcOption(page, configuredRule.getByRole('combobox', { name: 'Select a filter' }), 'Profile field value')
await selectNcOption(page, configuredRule.locator('.comparator [role="combobox"]'), 'is')
await selectNcOption(page, configuredRule.getByRole('combobox', { name: /^(Filter|Select a filter)$/ }), 'Profile field value')
await selectNcOption(page, configuredRule.getByRole('combobox', { name: /^(Comparator|Select a comparator)$/ }), 'is')

const checkEditor = configuredRule.locator('oca-profile-fields-check-user-profile-field')
await expect(checkEditor).toBeVisible()
Expand All @@ -70,8 +72,8 @@ const configureDraftRule = async(page: Page, actionName: string, label: string,
await expect(configuredRule.getByRole('button', { name: 'Save' })).toBeVisible()
await configuredRule.getByRole('button', { name: 'Save' }).click()

await expect(page.locator('.section.rule')).toHaveCount(initialRuleCount + 1)
const savedRule = page.locator('.section.rule').nth(configuredRuleIndex)
await expect(page.locator(workflowRuleSelector)).toHaveCount(initialRuleCount + 1)
const savedRule = page.locator(workflowRuleSelector).nth(configuredRuleIndex)
await expect(savedRule.getByText('Profile field value updated', { exact: true })).toBeVisible()
await expect(savedRule.getByText('Active', { exact: true })).toBeVisible()

Expand All @@ -92,6 +94,17 @@ test.beforeEach(async ({ page }) => {
await login(page.request, adminUser, adminPassword)
})

test('admin sees profile field flow icons in the card text color', async ({ page }) => {
await page.goto('./settings/admin/workflow')
const heading = page.getByRole('heading', { name: 'Log profile field change', exact: true }).first()
await expect(heading).toBeVisible()

const icon = heading.locator('xpath=../../*[1]')
await expect(icon).toHaveCSS('filter', 'none')
await expect(icon).toHaveCSS('background-image', 'none')
await expect(icon).toHaveCSS('mask-image', /url\(/)
})

test('admin can create a profile field workflow rule', async ({ page }) => {
const suffix = Date.now()
const fieldKey = `playwright_workflow_${suffix}`
Expand All @@ -105,7 +118,7 @@ test('admin can create a profile field workflow rule', async ({ page }) => {
const { savedRule, initialRuleCount } = await configureDraftRule(page, 'Log profile field change', label, fieldValue)

await savedRule.getByRole('button', { name: 'Delete' }).click()
await expect(page.locator('.section.rule')).toHaveCount(initialRuleCount)
await expect(page.locator(workflowRuleSelector)).toHaveCount(initialRuleCount)
await deleteDefinitionByFieldKey(page.request, fieldKey)
})

Expand All @@ -126,7 +139,7 @@ test('admin can create a send webhook workflow rule', async ({ page }) => {
}, webhookUrl)

await savedRule.getByRole('button', { name: 'Delete' }).click()
await expect(page.locator('.section.rule')).toHaveCount(initialRuleCount)
await expect(page.locator(workflowRuleSelector)).toHaveCount(initialRuleCount)
await deleteDefinitionByFieldKey(page.request, fieldKey)
})

Expand All @@ -146,7 +159,7 @@ test('admin can create an email affected user workflow rule', async ({ page }) =
})

await savedRule.getByRole('button', { name: 'Delete' }).click()
await expect(page.locator('.section.rule')).toHaveCount(initialRuleCount)
await expect(page.locator(workflowRuleSelector)).toHaveCount(initialRuleCount)
await deleteDefinitionByFieldKey(page.request, fieldKey)
})

Expand All @@ -172,7 +185,7 @@ test('admin can create a notify admins or groups workflow rule', async ({ page }
})

await savedRule.getByRole('button', { name: 'Delete' }).click()
await expect(page.locator('.section.rule')).toHaveCount(initialRuleCount)
await expect(page.locator(workflowRuleSelector)).toHaveCount(initialRuleCount)
await deleteDefinitionByFieldKey(page.request, fieldKey)
})

Expand All @@ -189,6 +202,6 @@ test('admin can create a create Talk conversation workflow rule', async ({ page
const { savedRule, initialRuleCount } = await configureDraftRule(page, 'Create Talk conversation', label, fieldValue)

await savedRule.getByRole('button', { name: 'Delete' }).click()
await expect(page.locator('.section.rule')).toHaveCount(initialRuleCount)
await expect(page.locator(workflowRuleSelector)).toHaveCount(initialRuleCount)
await deleteDefinitionByFieldKey(page.request, fieldKey)
})
172 changes: 172 additions & 0 deletions src/tests/utils/workflowEngineStore.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

import { describe, expect, it, vi } from 'vitest'

import {
applyDefaultTriggerToCreatedRule,
getWorkflowEngineStore,
type WorkflowEngineRule,
type WorkflowEngineStore,
} from '../../utils/workflowEngineStore.ts'

const entityClass = 'OCA\\ProfileFields\\Workflow\\ProfileFieldValueEntity'
const eventClass = 'OCA\\ProfileFields\\Workflow\\Event\\ProfileFieldValueUpdatedEvent'
const operationClass = 'OCA\\ProfileFields\\Workflow\\LogProfileFieldChangeOperation'
const fileEntityClass = 'OCA\\WorkflowEngine\\Entity\\File'

const defaults = {
entityClass,
eventClass,
operationClasses: [operationClass],
}

const entities = [
{ id: fileEntityClass, events: [{ eventName: 'postCreate', displayName: 'File created' }] },
{
id: entityClass,
events: [
{ eventName: 'otherEvent', displayName: 'Other' },
{ eventName: eventClass, displayName: 'Profile field value updated' },
],
},
]

const createRule = (overrides: Partial<WorkflowEngineRule> = {}): WorkflowEngineRule => ({
id: -1,
class: operationClass,
entity: fileEntityClass,
events: ['postCreate'],
...overrides,
})

const createStore = (): WorkflowEngineStore => ({
getEntities: () => entities,
setRuleTrigger: vi.fn(),
onRuleCreated: vi.fn(),
})

describe('getWorkflowEngineStore', () => {
it('returns null without a workflow engine root', () => {
expect(getWorkflowEngineStore(null)).toBeNull()
})

it('returns null when the root has no mounted store', () => {
expect(getWorkflowEngineStore(document.createElement('div'))).toBeNull()
})

it('adapts the Vuex store of the Vue 2 workflow engine', () => {
const rule = createRule()
const state = { rules: [createRule({ id: 7 }), rule], entities }
let subscriber: ((mutation: { type: string }, mutationState: typeof state) => void) | undefined
const vuexStore = {
state,
commit: vi.fn(),
subscribe: vi.fn((handler: (mutation: { type: string }, mutationState: typeof state) => void) => {
subscriber = handler
}),
}
const root = Object.assign(document.createElement('div'), { __vue__: { $store: vuexStore } })

const store = getWorkflowEngineStore(root)
const callback = vi.fn()
store?.onRuleCreated(callback)
subscriber?.({ type: 'updateRule' }, state)
subscriber?.({ type: 'addRule' }, state)
store?.setRuleTrigger(rule, entityClass, [eventClass])

expect(store?.getEntities()).toBe(entities)
expect(callback).toHaveBeenCalledOnce()
expect(callback).toHaveBeenCalledWith(rule)
expect(vuexStore.commit).toHaveBeenCalledWith('updateRule', { ...rule, entity: entityClass, events: [eventClass] })
})

it('adapts the Pinia store of the Vue 3 workflow engine', () => {
const rule = createRule()
let actionHandler: ((context: { name: string, after: (callback: () => void) => void }) => void) | undefined
const piniaStore = {
entities,
rules: [createRule({ id: 7 }), rule],
setRuleTrigger: vi.fn(),
$onAction: vi.fn((handler: (context: { name: string, after: (callback: () => void) => void }) => void) => {
actionHandler = handler
}),
}
const root = Object.assign(document.createElement('div'), {
__vue_app__: { config: { globalProperties: { $pinia: { _s: new Map([['workflowengine', piniaStore]]) } } } },
})

const store = getWorkflowEngineStore(root)
const callback = vi.fn()
store?.onRuleCreated(callback)
actionHandler?.({ name: 'updateRule', after: (afterCallback) => afterCallback() })
actionHandler?.({ name: 'createNewRule', after: (afterCallback) => afterCallback() })
store?.setRuleTrigger(rule, entityClass, [eventClass])

expect(store?.getEntities()).toBe(entities)
expect(callback).toHaveBeenCalledOnce()
expect(callback).toHaveBeenCalledWith(rule)
expect(piniaStore.setRuleTrigger).toHaveBeenCalledWith(rule, entityClass, [eventClass])
})

it('ignores a Pinia instance without the workflow engine store', () => {
const root = Object.assign(document.createElement('div'), {
__vue_app__: { config: { globalProperties: { $pinia: { _s: new Map([['other', {}]]) } } } },
})

expect(getWorkflowEngineStore(root)).toBeNull()
})
})

describe('applyDefaultTriggerToCreatedRule', () => {
it('points a new profile field rule at the profile field update event', () => {
const rule = createRule()
const store = createStore()

applyDefaultTriggerToCreatedRule(store, rule, defaults)

expect(store.setRuleTrigger).toHaveBeenCalledOnce()
expect(store.setRuleTrigger).toHaveBeenCalledWith(rule, entityClass, [eventClass])
})

it('ignores rules of other operations so profile field drafts keep their trigger', () => {
const store = createStore()

applyDefaultTriggerToCreatedRule(store, createRule({ class: 'OCA\\FilesAccessControl\\Operation' }), defaults)

expect(store.setRuleTrigger).not.toHaveBeenCalled()
})

it('ignores saved rules', () => {
const store = createStore()

applyDefaultTriggerToCreatedRule(store, createRule({ id: 3 }), defaults)

expect(store.setRuleTrigger).not.toHaveBeenCalled()
})

it('keeps a rule that already uses the default trigger', () => {
const store = createStore()

applyDefaultTriggerToCreatedRule(store, createRule({ entity: entityClass, events: [eventClass] }), defaults)

expect(store.setRuleTrigger).not.toHaveBeenCalled()
})

it('falls back to the first event of the entity', () => {
const rule = createRule()
const store = createStore()

applyDefaultTriggerToCreatedRule(store, rule, { ...defaults, eventClass: 'missingEvent' })

expect(store.setRuleTrigger).toHaveBeenCalledWith(rule, entityClass, ['otherEvent'])
})

it('does nothing when the profile field entity is not available', () => {
const store = createStore()

applyDefaultTriggerToCreatedRule(store, createRule(), { ...defaults, entityClass: 'missingEntity' })

expect(store.setRuleTrigger).not.toHaveBeenCalled()
})
})
Loading
Loading