From 99a278556a0c64c7c41125e7309527129469ae6f Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Sat, 3 Oct 2026 10:40:02 -0400 Subject: [PATCH 1/2] fix(workflow): support the Vue 3 workflow engine of Nextcloud 36 Signed-off-by: YvesCesar --- playwright/e2e/workflow.spec.ts | 48 +++--- src/tests/utils/workflowEngineStore.spec.ts | 168 ++++++++++++++++++++ src/utils/workflowEngineStore.ts | 142 +++++++++++++++++ src/workflow.ts | 118 ++------------ 4 files changed, 349 insertions(+), 127 deletions(-) create mode 100644 src/tests/utils/workflowEngineStore.spec.ts create mode 100644 src/utils/workflowEngineStore.ts diff --git a/playwright/e2e/workflow.spec.ts b/playwright/e2e/workflow.spec.ts index ec2bc823..e1eff20f 100644 --- a/playwright/e2e/workflow.spec.ts +++ b/playwright/e2e/workflow.spec.ts @@ -12,6 +12,8 @@ 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({ @@ -19,8 +21,8 @@ const selectNcOption = async(page: Page, combobox: Locator, optionName: string) }).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 } @@ -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, 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() @@ -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() @@ -105,7 +107,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) }) @@ -126,7 +128,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) }) @@ -146,7 +148,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) }) @@ -172,7 +174,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) }) @@ -189,6 +191,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) }) diff --git a/src/tests/utils/workflowEngineStore.spec.ts b/src/tests/utils/workflowEngineStore.spec.ts new file mode 100644 index 00000000..e12bba3a --- /dev/null +++ b/src/tests/utils/workflowEngineStore.spec.ts @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it, vi } from 'vitest' + +import { + applyDefaultTriggerToNewestRule, + 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 => ({ + id: -1, + class: operationClass, + entity: fileEntityClass, + events: ['postCreate'], + ...overrides, +}) + +const createStore = (rules: WorkflowEngineRule[]): WorkflowEngineStore => ({ + getEntities: () => entities, + getRules: () => rules, + 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() + let subscriber: ((mutation: { type: string }) => void) | undefined + const vuexStore = { + state: { rules: [rule], entities }, + commit: vi.fn(), + subscribe: vi.fn((handler: (mutation: { type: string }) => 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' }) + subscriber?.({ type: 'addRule' }) + store?.setRuleTrigger(rule, entityClass, [eventClass]) + + expect(store?.getEntities()).toBe(entities) + expect(store?.getRules()).toEqual([rule]) + expect(callback).toHaveBeenCalledTimes(1) + 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: [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(store?.getRules()).toEqual([rule]) + expect(callback).toHaveBeenCalledTimes(1) + 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('applyDefaultTriggerToNewestRule', () => { + it('points the newest unsaved profile field rule at the profile field update event', () => { + const olderRule = createRule({ id: -2 }) + const newestRule = createRule({ id: -1 }) + const store = createStore([createRule({ id: 5 }), olderRule, newestRule]) + + applyDefaultTriggerToNewestRule(store, defaults) + + expect(store.setRuleTrigger).toHaveBeenCalledOnce() + expect(store.setRuleTrigger).toHaveBeenCalledWith(newestRule, entityClass, [eventClass]) + }) + + it('ignores saved rules and rules of other operations', () => { + const store = createStore([ + createRule({ id: 3 }), + createRule({ id: -1, class: 'OCA\\FilesAccessControl\\Operation' }), + ]) + + applyDefaultTriggerToNewestRule(store, defaults) + + expect(store.setRuleTrigger).not.toHaveBeenCalled() + }) + + it('keeps a rule that already uses the default trigger', () => { + const store = createStore([createRule({ entity: entityClass, events: [eventClass] })]) + + applyDefaultTriggerToNewestRule(store, defaults) + + expect(store.setRuleTrigger).not.toHaveBeenCalled() + }) + + it('falls back to the first event of the entity', () => { + const rule = createRule() + const store = createStore([rule]) + + applyDefaultTriggerToNewestRule(store, { ...defaults, eventClass: 'missingEvent' }) + + expect(store.setRuleTrigger).toHaveBeenCalledWith(rule, entityClass, ['otherEvent']) + }) + + it('does nothing when the profile field entity is not available', () => { + const store = createStore([createRule()]) + + applyDefaultTriggerToNewestRule(store, { ...defaults, entityClass: 'missingEntity' }) + + expect(store.setRuleTrigger).not.toHaveBeenCalled() + }) +}) diff --git a/src/utils/workflowEngineStore.ts b/src/utils/workflowEngineStore.ts new file mode 100644 index 00000000..63f0642a --- /dev/null +++ b/src/utils/workflowEngineStore.ts @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +export type WorkflowEngineEntity = { + id: string + events: Array<{ + eventName: string + displayName: string + }> +} + +export type WorkflowEngineRule = { + id: number + class: string + entity: string + events: string[] +} + +export type WorkflowEngineStore = { + getEntities: () => WorkflowEngineEntity[] + getRules: () => WorkflowEngineRule[] + setRuleTrigger: (rule: WorkflowEngineRule, entity: string, events: string[]) => void + onRuleCreated: (callback: () => void) => void +} + +export type WorkflowRuleDefaults = { + entityClass: string + eventClass: string + operationClasses: string[] +} + +type VuexWorkflowStore = { + state: { + rules: WorkflowEngineRule[] + entities: WorkflowEngineEntity[] + } + commit: (type: string, payload?: unknown) => void + subscribe: (handler: (mutation: { type: string }) => void) => unknown +} + +type PiniaActionContext = { + name: string + after: (callback: () => void) => void +} + +type PiniaWorkflowStore = { + entities: WorkflowEngineEntity[] + rules: WorkflowEngineRule[] + setRuleTrigger: (rule: WorkflowEngineRule, entity: string, events: string[]) => void + $onAction: (handler: (context: PiniaActionContext) => void) => unknown +} + +type WorkflowEngineRootElement = Element & { + __vue__?: { $store?: VuexWorkflowStore } + __vue_app__?: { config?: { globalProperties?: { $pinia?: { _s?: Map } } } } +} + +const piniaStoreId = 'workflowengine' + +const createVuexAdapter = (store: VuexWorkflowStore): WorkflowEngineStore => ({ + getEntities: () => store.state.entities, + getRules: () => store.state.rules, + setRuleTrigger: (rule, entity, events) => store.commit('updateRule', { ...rule, entity, events }), + onRuleCreated: (callback) => { + store.subscribe((mutation) => { + if (mutation.type === 'addRule') { + callback() + } + }) + }, +}) + +const createPiniaAdapter = (store: PiniaWorkflowStore): WorkflowEngineStore => ({ + getEntities: () => store.entities, + getRules: () => store.rules, + setRuleTrigger: (rule, entity, events) => store.setRuleTrigger(rule, entity, events), + onRuleCreated: (callback) => { + store.$onAction(({ name, after }) => { + if (name === 'createNewRule') { + after(callback) + } + }) + }, +}) + +const isPiniaWorkflowStore = (store: unknown): store is PiniaWorkflowStore => { + return typeof store === 'object' + && store !== null + && typeof (store as PiniaWorkflowStore).$onAction === 'function' + && typeof (store as PiniaWorkflowStore).setRuleTrigger === 'function' +} + +export const getWorkflowEngineStore = (root: Element | null): WorkflowEngineStore | null => { + if (root === null) { + return null + } + + const workflowRoot = root as WorkflowEngineRootElement + const vuexStore = workflowRoot.__vue__?.$store + if (vuexStore !== undefined) { + return createVuexAdapter(vuexStore) + } + + const piniaStore = workflowRoot.__vue_app__?.config?.globalProperties?.$pinia?._s?.get(piniaStoreId) + if (isPiniaWorkflowStore(piniaStore)) { + return createPiniaAdapter(piniaStore) + } + + return null +} + +const getDefaultEventName = (store: WorkflowEngineStore, defaults: WorkflowRuleDefaults): string | null => { + const entity = store.getEntities().find((item) => item.id === defaults.entityClass) + if (entity === undefined) { + return null + } + + return entity.events.find((event) => event.eventName === defaults.eventClass)?.eventName + ?? entity.events[0]?.eventName + ?? null +} + +export const applyDefaultTriggerToNewestRule = (store: WorkflowEngineStore, defaults: WorkflowRuleDefaults): void => { + const defaultEventName = getDefaultEventName(store, defaults) + if (defaultEventName === null) { + return + } + + const targetRule = [...store.getRules()] + .reverse() + .find((rule) => defaults.operationClasses.includes(rule.class) && rule.id < 0) + + if (targetRule === undefined) { + return + } + + if (targetRule.entity === defaults.entityClass && targetRule.events.length === 1 && targetRule.events[0] === defaultEventName) { + return + } + + store.setRuleTrigger(targetRule, defaults.entityClass, [defaultEventName]) +} diff --git a/src/workflow.ts b/src/workflow.ts index 4c79c48f..72e0d938 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -7,6 +7,7 @@ import { createApp, h, reactive, type App } from 'vue' import { listDefinitions, searchWorkflowTargetSuggestions, type WorkflowTargetSuggestion } from './api.ts' import type { FieldDefinition } from './types/index.ts' +import { applyDefaultTriggerToNewestRule, getWorkflowEngineStore } from './utils/workflowEngineStore.ts' import { getWorkflowOperatorKeys, isWorkflowOperatorSupported, @@ -72,39 +73,6 @@ type WorkflowEngineApi = { registerOperator: (plugin: WorkflowEngineOperatorPlugin) => void } -type WorkflowEngineEntity = { - id: string - events: Array<{ - eventName: string - displayName: string - }> -} - -type WorkflowEngineRule = { - id: number - class: string - entity: string - events: string[] - name: string - checks: Array<{ class: string | null, operator: string | null, value: string }> - operation: string - valid?: boolean -} - -type WorkflowEngineStore = { - state: { - rules: WorkflowEngineRule[] - entities: WorkflowEngineEntity[] - } - commit: (type: string, payload?: WorkflowEngineRule) => void - dispatch: (type: string, payload?: unknown) => Promise | unknown -} - -type WorkflowEngineRootVm = { - $store: WorkflowEngineStore - createNewRule: (operation: { id: string }) => Promise | unknown -} - const workflowCheckClass = 'OCA\\ProfileFields\\Workflow\\UserProfileFieldCheck' const workflowOperationClasses = [ 'OCA\\ProfileFields\\Workflow\\LogProfileFieldChangeOperation', @@ -1188,25 +1156,11 @@ const applyWorkflowCardTheme = (): void => { if (icon !== null && backgroundImage !== '' && backgroundImage !== 'none') { icon.style.setProperty('--profile-fields-workflow-icon', backgroundImage) } - - const addFlowButton = card.querySelector('button') - if (card.classList.contains('colored') && addFlowButton !== null && addFlowButton.dataset.profileFieldsWorkflowTriggerBound !== 'true') { - addFlowButton.dataset.profileFieldsWorkflowTriggerBound = 'true' - addFlowButton.addEventListener('click', () => { - window.setTimeout(() => { - const store = getWorkflowStore() - if (store !== null) { - applyDefaultTriggerToNewestWorkflowRule(store) - } - }, 0) - }) - } } } let workflowCardThemeObserver: MutationObserver | null = null -let workflowDefaultsPatchAttempts = 0 -let workflowDefaultsPatched = false +let workflowRuleDefaultsAttempts = 0 const observeWorkflowCards = (): void => { applyWorkflowCardTheme() @@ -1234,74 +1188,30 @@ const startWorkflowCardTheme = (): void => { window.setTimeout(() => observeWorkflowCards(), 0) } -const getWorkflowRootVm = (): WorkflowEngineRootVm | null => { - const root = document.querySelector('#workflowengine') as (HTMLElement & { __vue__?: WorkflowEngineRootVm }) | null - return root?.__vue__ ?? null -} - -const getWorkflowStore = (): WorkflowEngineStore | null => { - return getWorkflowRootVm()?.$store ?? null +const workflowRuleDefaults = { + entityClass: workflowEntityClass, + eventClass: workflowUpdatedEventClass, + operationClasses: workflowOperationClasses, } -const getDefaultWorkflowEventName = (store: WorkflowEngineStore): string | null => { - const entity = store.state.entities.find((item) => item.id === workflowEntityClass) - if (entity === undefined) { - return null - } - - return entity.events.find((event) => event.eventName === workflowUpdatedEventClass)?.eventName - ?? entity.events[0]?.eventName - ?? null -} - -const applyDefaultTriggerToNewestWorkflowRule = (store: WorkflowEngineStore): void => { - const defaultEventName = getDefaultWorkflowEventName(store) - if (defaultEventName === null) { - return - } - - const targetRule = [...store.state.rules] - .reverse() - .find((rule) => workflowOperationClasses.includes(rule.class) && rule.id < 0) - - if (targetRule === undefined) { - return - } - - if (targetRule.entity === workflowEntityClass && targetRule.events.length === 1 && targetRule.events[0] === defaultEventName) { +const applyWorkflowRuleDefaults = (): void => { + const store = getWorkflowEngineStore(document.querySelector('#workflowengine')) + if (store !== null) { + store.onRuleCreated(() => applyDefaultTriggerToNewestRule(store, workflowRuleDefaults)) return } - store.commit('updateRule', { - ...targetRule, - entity: workflowEntityClass, - events: [defaultEventName], - }) -} - -const patchWorkflowCreateRuleDefaults = (): void => { - if (workflowDefaultsPatched) { - return - } - - const store = getWorkflowStore() - const rootVm = getWorkflowRootVm() - if (store === null || rootVm === null) { - if (workflowDefaultsPatchAttempts >= 20) { - return - } - - workflowDefaultsPatchAttempts += 1 - window.setTimeout(patchWorkflowCreateRuleDefaults, 50) + if (workflowRuleDefaultsAttempts >= 20) { return } - workflowDefaultsPatched = true + workflowRuleDefaultsAttempts += 1 + window.setTimeout(applyWorkflowRuleDefaults, 50) } void loadDefinitions() startWorkflowCardTheme() -patchWorkflowCreateRuleDefaults() +applyWorkflowRuleDefaults() let registrationAttempts = 0 From e4de48fc968e1f079e5c2f808715e2daddcb8e36 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Sat, 3 Oct 2026 10:53:12 -0400 Subject: [PATCH 2/2] fix(workflow): keep draft triggers and theme flow cards on Nextcloud 36 Signed-off-by: YvesCesar --- playwright/e2e/workflow.spec.ts | 11 ++++ src/tests/utils/workflowEngineStore.spec.ts | 68 +++++++++++---------- src/utils/workflowEngineStore.ts | 41 +++++++------ src/workflow.ts | 51 ++++++++-------- 4 files changed, 92 insertions(+), 79 deletions(-) diff --git a/playwright/e2e/workflow.spec.ts b/playwright/e2e/workflow.spec.ts index e1eff20f..89d87c76 100644 --- a/playwright/e2e/workflow.spec.ts +++ b/playwright/e2e/workflow.spec.ts @@ -94,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}` diff --git a/src/tests/utils/workflowEngineStore.spec.ts b/src/tests/utils/workflowEngineStore.spec.ts index e12bba3a..06d6ec17 100644 --- a/src/tests/utils/workflowEngineStore.spec.ts +++ b/src/tests/utils/workflowEngineStore.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { - applyDefaultTriggerToNewestRule, + applyDefaultTriggerToCreatedRule, getWorkflowEngineStore, type WorkflowEngineRule, type WorkflowEngineStore, @@ -40,9 +40,8 @@ const createRule = (overrides: Partial = {}): WorkflowEngine ...overrides, }) -const createStore = (rules: WorkflowEngineRule[]): WorkflowEngineStore => ({ +const createStore = (): WorkflowEngineStore => ({ getEntities: () => entities, - getRules: () => rules, setRuleTrigger: vi.fn(), onRuleCreated: vi.fn(), }) @@ -58,11 +57,12 @@ describe('getWorkflowEngineStore', () => { it('adapts the Vuex store of the Vue 2 workflow engine', () => { const rule = createRule() - let subscriber: ((mutation: { type: string }) => void) | undefined + const state = { rules: [createRule({ id: 7 }), rule], entities } + let subscriber: ((mutation: { type: string }, mutationState: typeof state) => void) | undefined const vuexStore = { - state: { rules: [rule], entities }, + state, commit: vi.fn(), - subscribe: vi.fn((handler: (mutation: { type: string }) => void) => { + subscribe: vi.fn((handler: (mutation: { type: string }, mutationState: typeof state) => void) => { subscriber = handler }), } @@ -71,13 +71,13 @@ describe('getWorkflowEngineStore', () => { const store = getWorkflowEngineStore(root) const callback = vi.fn() store?.onRuleCreated(callback) - subscriber?.({ type: 'updateRule' }) - subscriber?.({ type: 'addRule' }) + subscriber?.({ type: 'updateRule' }, state) + subscriber?.({ type: 'addRule' }, state) store?.setRuleTrigger(rule, entityClass, [eventClass]) expect(store?.getEntities()).toBe(entities) - expect(store?.getRules()).toEqual([rule]) - expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledOnce() + expect(callback).toHaveBeenCalledWith(rule) expect(vuexStore.commit).toHaveBeenCalledWith('updateRule', { ...rule, entity: entityClass, events: [eventClass] }) }) @@ -86,7 +86,7 @@ describe('getWorkflowEngineStore', () => { let actionHandler: ((context: { name: string, after: (callback: () => void) => void }) => void) | undefined const piniaStore = { entities, - rules: [rule], + rules: [createRule({ id: 7 }), rule], setRuleTrigger: vi.fn(), $onAction: vi.fn((handler: (context: { name: string, after: (callback: () => void) => void }) => void) => { actionHandler = handler @@ -104,8 +104,8 @@ describe('getWorkflowEngineStore', () => { store?.setRuleTrigger(rule, entityClass, [eventClass]) expect(store?.getEntities()).toBe(entities) - expect(store?.getRules()).toEqual([rule]) - expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledOnce() + expect(callback).toHaveBeenCalledWith(rule) expect(piniaStore.setRuleTrigger).toHaveBeenCalledWith(rule, entityClass, [eventClass]) }) @@ -118,50 +118,54 @@ describe('getWorkflowEngineStore', () => { }) }) -describe('applyDefaultTriggerToNewestRule', () => { - it('points the newest unsaved profile field rule at the profile field update event', () => { - const olderRule = createRule({ id: -2 }) - const newestRule = createRule({ id: -1 }) - const store = createStore([createRule({ id: 5 }), olderRule, newestRule]) +describe('applyDefaultTriggerToCreatedRule', () => { + it('points a new profile field rule at the profile field update event', () => { + const rule = createRule() + const store = createStore() - applyDefaultTriggerToNewestRule(store, defaults) + applyDefaultTriggerToCreatedRule(store, rule, defaults) expect(store.setRuleTrigger).toHaveBeenCalledOnce() - expect(store.setRuleTrigger).toHaveBeenCalledWith(newestRule, entityClass, [eventClass]) + 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 and rules of other operations', () => { - const store = createStore([ - createRule({ id: 3 }), - createRule({ id: -1, class: 'OCA\\FilesAccessControl\\Operation' }), - ]) + it('ignores saved rules', () => { + const store = createStore() - applyDefaultTriggerToNewestRule(store, defaults) + applyDefaultTriggerToCreatedRule(store, createRule({ id: 3 }), defaults) expect(store.setRuleTrigger).not.toHaveBeenCalled() }) it('keeps a rule that already uses the default trigger', () => { - const store = createStore([createRule({ entity: entityClass, events: [eventClass] })]) + const store = createStore() - applyDefaultTriggerToNewestRule(store, defaults) + 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([rule]) + const store = createStore() - applyDefaultTriggerToNewestRule(store, { ...defaults, eventClass: 'missingEvent' }) + 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([createRule()]) + const store = createStore() - applyDefaultTriggerToNewestRule(store, { ...defaults, entityClass: 'missingEntity' }) + applyDefaultTriggerToCreatedRule(store, createRule(), { ...defaults, entityClass: 'missingEntity' }) expect(store.setRuleTrigger).not.toHaveBeenCalled() }) diff --git a/src/utils/workflowEngineStore.ts b/src/utils/workflowEngineStore.ts index 63f0642a..38a13979 100644 --- a/src/utils/workflowEngineStore.ts +++ b/src/utils/workflowEngineStore.ts @@ -18,9 +18,8 @@ export type WorkflowEngineRule = { export type WorkflowEngineStore = { getEntities: () => WorkflowEngineEntity[] - getRules: () => WorkflowEngineRule[] setRuleTrigger: (rule: WorkflowEngineRule, entity: string, events: string[]) => void - onRuleCreated: (callback: () => void) => void + onRuleCreated: (callback: (rule: WorkflowEngineRule) => void) => void } export type WorkflowRuleDefaults = { @@ -35,7 +34,7 @@ type VuexWorkflowStore = { entities: WorkflowEngineEntity[] } commit: (type: string, payload?: unknown) => void - subscribe: (handler: (mutation: { type: string }) => void) => unknown + subscribe: (handler: (mutation: { type: string }, state: VuexWorkflowStore['state']) => void) => unknown } type PiniaActionContext = { @@ -59,12 +58,12 @@ const piniaStoreId = 'workflowengine' const createVuexAdapter = (store: VuexWorkflowStore): WorkflowEngineStore => ({ getEntities: () => store.state.entities, - getRules: () => store.state.rules, setRuleTrigger: (rule, entity, events) => store.commit('updateRule', { ...rule, entity, events }), onRuleCreated: (callback) => { - store.subscribe((mutation) => { - if (mutation.type === 'addRule') { - callback() + store.subscribe((mutation, state) => { + const createdRule = state.rules.at(-1) + if (mutation.type === 'addRule' && createdRule !== undefined) { + callback(createdRule) } }) }, @@ -72,13 +71,19 @@ const createVuexAdapter = (store: VuexWorkflowStore): WorkflowEngineStore => ({ const createPiniaAdapter = (store: PiniaWorkflowStore): WorkflowEngineStore => ({ getEntities: () => store.entities, - getRules: () => store.rules, setRuleTrigger: (rule, entity, events) => store.setRuleTrigger(rule, entity, events), onRuleCreated: (callback) => { store.$onAction(({ name, after }) => { - if (name === 'createNewRule') { - after(callback) + if (name !== 'createNewRule') { + return } + + after(() => { + const createdRule = store.rules.at(-1) + if (createdRule !== undefined) { + callback(createdRule) + } + }) }) }, }) @@ -120,23 +125,19 @@ const getDefaultEventName = (store: WorkflowEngineStore, defaults: WorkflowRuleD ?? null } -export const applyDefaultTriggerToNewestRule = (store: WorkflowEngineStore, defaults: WorkflowRuleDefaults): void => { - const defaultEventName = getDefaultEventName(store, defaults) - if (defaultEventName === null) { +export const applyDefaultTriggerToCreatedRule = (store: WorkflowEngineStore, rule: WorkflowEngineRule, defaults: WorkflowRuleDefaults): void => { + if (!defaults.operationClasses.includes(rule.class) || rule.id >= 0) { return } - const targetRule = [...store.getRules()] - .reverse() - .find((rule) => defaults.operationClasses.includes(rule.class) && rule.id < 0) - - if (targetRule === undefined) { + const defaultEventName = getDefaultEventName(store, defaults) + if (defaultEventName === null) { return } - if (targetRule.entity === defaults.entityClass && targetRule.events.length === 1 && targetRule.events[0] === defaultEventName) { + if (rule.entity === defaults.entityClass && rule.events.length === 1 && rule.events[0] === defaultEventName) { return } - store.setRuleTrigger(targetRule, defaults.entityClass, [defaultEventName]) + store.setRuleTrigger(rule, defaults.entityClass, [defaultEventName]) } diff --git a/src/workflow.ts b/src/workflow.ts index 72e0d938..9a08cea2 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -7,7 +7,7 @@ import { createApp, h, reactive, type App } from 'vue' import { listDefinitions, searchWorkflowTargetSuggestions, type WorkflowTargetSuggestion } from './api.ts' import type { FieldDefinition } from './types/index.ts' -import { applyDefaultTriggerToNewestRule, getWorkflowEngineStore } from './utils/workflowEngineStore.ts' +import { applyDefaultTriggerToCreatedRule, getWorkflowEngineStore } from './utils/workflowEngineStore.ts' import { getWorkflowOperatorKeys, isWorkflowOperatorSupported, @@ -94,8 +94,9 @@ const workflowOperationNames = new Set([ t('profile_fields', 'Create Talk conversation'), t('profile_fields', 'Send webhook'), ]) -const workflowCardClassName = 'profile-fields-workflow-card' const workflowItemClassName = 'profile-fields-workflow-item' +const workflowDescriptionClassName = 'profile-fields-workflow-description' +const workflowIconClassName = 'profile-fields-workflow-icon' const workflowCardThemeStyleId = 'profile-fields-workflow-card-theme' // TRANSLATORS The labels below are technical workflow operators shown in filter dropdowns. @@ -1101,21 +1102,17 @@ const ensureWorkflowCardThemeStyle = (): void => { const style = document.createElement('style') style.id = workflowCardThemeStyleId style.textContent = ` - .actions__item.${workflowItemClassName} { - color: var(--color-main-text); + .${workflowItemClassName}, + .${workflowItemClassName} .${workflowDescriptionClassName}, + .${workflowItemClassName} h3 { + color: var(--color-main-text) !important; } - .actions__item.${workflowItemClassName} .actions__item__description h3, - .actions__item.${workflowItemClassName} .actions__item__description small, - .actions__item.${workflowItemClassName} .actions__item__description { - color: var(--color-main-text); + .${workflowItemClassName} small { + color: color-mix(in srgb, var(--color-main-text) 78%, transparent) !important; } - .actions__item.${workflowItemClassName} .actions__item__description small { - color: color-mix(in srgb, var(--color-main-text) 78%, transparent); - } - - .actions__item.${workflowItemClassName} .icon { + .${workflowItemClassName} .${workflowIconClassName} { background-color: currentColor; background-image: none !important; mask-image: var(--profile-fields-workflow-icon); @@ -1133,27 +1130,27 @@ const ensureWorkflowCardThemeStyle = (): void => { document.head.append(style) } -const isWorkflowOperationCard = (element: Element): boolean => { - const heading = element.querySelector('.actions__item__description h3') - return workflowOperationNames.has(heading?.textContent?.trim() ?? '') -} - const applyWorkflowCardTheme = (): void => { ensureWorkflowCardThemeStyle() - for (const card of document.querySelectorAll('.actions__item')) { - if (!isWorkflowOperationCard(card)) { + for (const heading of document.querySelectorAll('h3')) { + if (!workflowOperationNames.has(heading.textContent?.trim() ?? '')) { continue } - card.classList.add(workflowItemClassName) - if (card.classList.contains('colored')) { - card.classList.add(workflowCardClassName) + const description = heading.parentElement + const card = description?.parentElement + const icon = card?.firstElementChild + if (!(description instanceof HTMLElement) || !(card instanceof HTMLElement) || !(icon instanceof HTMLElement) || icon === description) { + continue } - const icon = card.querySelector('.icon') - const backgroundImage = icon?.style.backgroundImage || (icon === null || icon === undefined ? '' : window.getComputedStyle(icon).backgroundImage) - if (icon !== null && backgroundImage !== '' && backgroundImage !== 'none') { + card.classList.add(workflowItemClassName) + description.classList.add(workflowDescriptionClassName) + icon.classList.add(workflowIconClassName) + + const backgroundImage = icon.style.backgroundImage || window.getComputedStyle(icon).backgroundImage + if (backgroundImage !== '' && backgroundImage !== 'none') { icon.style.setProperty('--profile-fields-workflow-icon', backgroundImage) } } @@ -1197,7 +1194,7 @@ const workflowRuleDefaults = { const applyWorkflowRuleDefaults = (): void => { const store = getWorkflowEngineStore(document.querySelector('#workflowengine')) if (store !== null) { - store.onRuleCreated(() => applyDefaultTriggerToNewestRule(store, workflowRuleDefaults)) + store.onRuleCreated((rule) => applyDefaultTriggerToCreatedRule(store, rule, workflowRuleDefaults)) return }