diff --git a/frappe/AutomationRule/AddBlock.vue b/frappe/AutomationRule/AddBlock.vue new file mode 100644 index 000000000..39955aaca --- /dev/null +++ b/frappe/AutomationRule/AddBlock.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frappe/AutomationRule/Automation.vue b/frappe/AutomationRule/Automation.vue new file mode 100644 index 000000000..ada6a189c --- /dev/null +++ b/frappe/AutomationRule/Automation.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/frappe/AutomationRule/AutomationLayout.vue b/frappe/AutomationRule/AutomationLayout.vue new file mode 100644 index 000000000..b0937efae --- /dev/null +++ b/frappe/AutomationRule/AutomationLayout.vue @@ -0,0 +1,28 @@ + + + + diff --git a/frappe/AutomationRule/AutomationList.vue b/frappe/AutomationRule/AutomationList.vue new file mode 100644 index 000000000..04d5acbb3 --- /dev/null +++ b/frappe/AutomationRule/AutomationList.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/frappe/AutomationRule/BaseBlock.vue b/frappe/AutomationRule/BaseBlock.vue new file mode 100644 index 000000000..4e9b35ae2 --- /dev/null +++ b/frappe/AutomationRule/BaseBlock.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/frappe/AutomationRule/ConditionBlock.vue b/frappe/AutomationRule/ConditionBlock.vue new file mode 100644 index 000000000..81b69c320 --- /dev/null +++ b/frappe/AutomationRule/ConditionBlock.vue @@ -0,0 +1,181 @@ + + + diff --git a/frappe/AutomationRule/ElseBlock.vue b/frappe/AutomationRule/ElseBlock.vue new file mode 100644 index 000000000..50bee93c0 --- /dev/null +++ b/frappe/AutomationRule/ElseBlock.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/frappe/AutomationRule/EmailBlock.vue b/frappe/AutomationRule/EmailBlock.vue new file mode 100644 index 000000000..7def0693c --- /dev/null +++ b/frappe/AutomationRule/EmailBlock.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/frappe/AutomationRule/IfElseBlock.vue b/frappe/AutomationRule/IfElseBlock.vue new file mode 100644 index 000000000..6145e2e3a --- /dev/null +++ b/frappe/AutomationRule/IfElseBlock.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/frappe/AutomationRule/NameBlock.vue b/frappe/AutomationRule/NameBlock.vue new file mode 100644 index 000000000..5db3becf5 --- /dev/null +++ b/frappe/AutomationRule/NameBlock.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/frappe/AutomationRule/RuleBlock.vue b/frappe/AutomationRule/RuleBlock.vue new file mode 100644 index 000000000..1088bdb89 --- /dev/null +++ b/frappe/AutomationRule/RuleBlock.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/frappe/AutomationRule/ScopeBlock.vue b/frappe/AutomationRule/ScopeBlock.vue new file mode 100644 index 000000000..b93e7a2e3 --- /dev/null +++ b/frappe/AutomationRule/ScopeBlock.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/frappe/AutomationRule/SetFieldBlock.vue b/frappe/AutomationRule/SetFieldBlock.vue new file mode 100644 index 000000000..97bd77191 --- /dev/null +++ b/frappe/AutomationRule/SetFieldBlock.vue @@ -0,0 +1,111 @@ + + + diff --git a/frappe/AutomationRule/WhenBlock.vue b/frappe/AutomationRule/WhenBlock.vue new file mode 100644 index 000000000..85238f310 --- /dev/null +++ b/frappe/AutomationRule/WhenBlock.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frappe/AutomationRule/automation.ts b/frappe/AutomationRule/automation.ts new file mode 100644 index 000000000..207f19653 --- /dev/null +++ b/frappe/AutomationRule/automation.ts @@ -0,0 +1,297 @@ +import { computed, h, inject, type Ref } from 'vue' +import TextInput from '../../src/components/TextInput/TextInput.vue' +import { useDoctypeMeta } from '../../src/data-fetching/useDoctypeMeta' +import type { StateRow } from '../Filter/types' +import { + getDefaultOperator, + getOperators, + getValueControl, +} from '../Filter/utils' +import { AutomationState, AutomationStateSymbol } from './types' + +// Raw field from useDoctypeMeta.getField() +interface DocFieldMeta { + fieldname: string + fieldtype: string + options?: string + label?: string +} + +// Type for getField function from useDoctypeMeta +type GetFieldFn = (fieldname: string) => DocFieldMeta | null + +// Condition tuple: [fieldname, operator, value] +type ConditionTuple = [string, string, string] +// Condition array with conjunctions: [tuple, "and", tuple, ...] +type ConditionArray = (ConditionTuple | string)[] + +export function useAutomationState(): AutomationState { + // this is done to ensure the state is always provided, + // inject by definition says the injected key could be undefined + const state = inject(AutomationStateSymbol) + if (!state) { + throw new Error('AutomationState must be provided') + } + return state +} + +// Creates an empty filter row +export const createEmptyRow = (): StateRow => ({ + field: { fieldName: '', fieldType: '', options: [] }, + operator: '', + value: '', +}) + +export function useFilterConditions(rows: StateRow[], getField: GetFieldFn) { + // Check if a row has a field selected + const isRowComplete = (row: StateRow): boolean => { + return !!row.field.fieldName + } + + // Check if we can add a new row (last row must have field selected) + const canAddRow = (): boolean => { + const lastRow = rows[rows.length - 1] + return lastRow ? isRowComplete(lastRow) : true + } + + // Insert a new empty row + const insertRow = () => { + rows.push(createEmptyRow()) + } + + const deleteRow = (index: number) => { + rows.splice(index, 1) + } + + const clearRows = () => { + rows.splice(0, rows.length) + insertRow() + } + + const updateField = (index: number, fieldName: string) => { + if (!fieldName) return + + const rawField = getField(fieldName) + if (!rawField) return + + const fieldType = rawField.fieldtype + const options = rawField.options?.split('\n') || [] + + const defaultOperator = getDefaultOperator({ + fieldType, + fieldName, + }) + + rows[index] = { + field: { + fieldName, + fieldType, + options, + }, + operator: defaultOperator, + value: '', + } + } + + return { + insertRow, + deleteRow, + clearRows, + updateField, + isRowComplete, + canAddRow, + } +} + +// the format for conditions becomes +// [["ticket_type","equals","Bug"],"and",["status","equals","Open"]] +export function useDoctypeFilters( + doctype: string, + conditions: Ref, +) { + const { fields, getField } = useDoctypeMeta(doctype) + + const conditionRows = computed(() => { + return conditions.value.filter((item): item is ConditionTuple => + Array.isArray(item), + ) + }) + + const conjunction = computed(() => { + for (const item of conditions.value) { + if (typeof item === 'string') { + return item + } + } + return 'and' + }) + + const conjunctionTooltip = computed(() => { + return conjunction.value === 'and' + ? 'Match ALL of the conditions' + : 'Match ANY of the conditions' + }) + + function toggleConjunction() { + const newConjunction = conjunction.value === 'and' ? 'or' : 'and' + for (let i = 0; i < conditions.value.length; i++) { + if (typeof conditions.value[i] === 'string') { + conditions.value[i] = newConjunction + } + } + } + + function getFieldTypeFromRow(row: ConditionTuple): string { + const fieldName = row[0] + if (!fieldName) return '' + const field = getField(fieldName) + return field?.fieldtype || '' + } + + function handleFieldChange(row: ConditionTuple, fieldName: string) { + if (!fieldName) { + row[0] = '' + row[1] = '' + row[2] = '' + return + } + + const rawField = getField(fieldName) + if (!rawField) return + + const defaultOperator = getDefaultOperator({ + fieldName: rawField.fieldname, + fieldType: rawField.fieldtype, + }) + + row[0] = fieldName + row[1] = defaultOperator + row[2] = '' + } + + function handleOperatorChange( + row: ConditionTuple, + operator: String | undefined, + ) { + if (!operator) return + row[1] = String(operator) + row[2] = '' + } + + function handleValueChange(row: ConditionTuple, value: unknown) { + row[2] = String(value ?? '') + } + + function getOperatorsForRow(row: ConditionTuple) { + const fieldName = row[0] + if (!fieldName) return [] + + const field = getField(fieldName) + if (!field) return [] + + return getOperators({ + fieldName: field.fieldname, + fieldType: field.fieldtype, + options: field.options?.split('\n') || [], + }) + } + + function getValueControlForRow(row: ConditionTuple) { + const fieldName = row[0] + const operator = row[1] + const defaultInputComponent = h(TextInput, { placeholder: 'Enter Value' }) + if (!fieldName) return defaultInputComponent + + const field = getField(fieldName) + if (!field) return defaultInputComponent + + return getValueControl({ + field: { + fieldName: field.fieldname, + fieldType: field.fieldtype, + options: field.options?.split('\n') || null, + }, + operator, + value: row[2], + }) + } + + function getFieldsForRow(currentFieldName: string) { + return fields.value.filter( + (f) => + f.value === currentFieldName || + !conditionRows.value.some((row) => row[0] === f.value), + ) + } + + function canAddRow(): boolean { + const lastRow = conditionRows.value[conditionRows.value.length - 1] + return lastRow ? !!lastRow[0] : true + } + + function insertRow() { + if (conditions.value.length === 0) { + conditions.value.push(['', '', '']) + } else { + conditions.value.push(conjunction.value, ['', '', '']) + } + } + + function getConditionArrayIndex(rowIndex: number): number { + let count = 0 + for (let i = 0; i < conditions.value.length; i++) { + if (Array.isArray(conditions.value[i])) { + if (count === rowIndex) return i + count++ + } + } + return -1 + } + + function deleteRow(index: number) { + const arrayIndex = getConditionArrayIndex(index) + if (arrayIndex === -1) return + + if (index === 0) { + if (conditions.value.length > 1) { + conditions.value.splice(0, 2) + } else { + conditions.value.splice(0, 1) + } + } else { + conditions.value.splice(arrayIndex - 1, 2) + } + } + + function clearRows() { + conditions.value.splice(0, conditions.value.length) + conditions.value.push(['', '', '']) + } + + return { + // Computed + conditionRows, + conjunction, + conjunctionTooltip, + // availableFields, + fields, + + // Row operations + insertRow, + deleteRow, + clearRows, + canAddRow, + + // Field/Operator/Value handlers + handleFieldChange, + handleOperatorChange, + handleValueChange, + toggleConjunction, + + // Helpers + getFieldTypeFromRow, + getOperatorsForRow, + getValueControlForRow, + getFieldsForRow, + } +} diff --git a/frappe/AutomationRule/types.ts b/frappe/AutomationRule/types.ts new file mode 100644 index 000000000..4bf3ad765 --- /dev/null +++ b/frappe/AutomationRule/types.ts @@ -0,0 +1,66 @@ +import { InjectionKey } from 'vue' + +// Re-export Dropdown types +export type { + DropdownItem, + DropdownOption, + DropdownOptions, +} from '../../src/components/Dropdown/types' + +export type IconType = + | '' + | 'scope' + | 'timer' + | 'event' + | 'condition' + | 'action' + | 'notification' + | 'filter' + | 'title' + | 'align' + +export type RoundedType = 'all' | 'top' | 'bottom' | 'none' + +// Condition types +export type ConditionTuple = [string, string, string] +export type ConditionArray = (ConditionTuple | string)[] + +// Action types +export interface SetAction { + type: 'set' + field: string + value: string +} +export interface SendEmailAction { + type: 'email' + to: string + via: 'rich_text' | 'template' + template?: string + text?: string +} + +// Block types +export interface IfBlockData { + type: 'if' + conditions: ConditionArray + actions: SetAction[] | SendEmailAction[] +} + +export interface ElseBlockData { + type: 'else' + conditions: string + actions: SetAction[] +} + +export interface AutomationState { + name: string + enabled: boolean + dt: string + eventType: 'created' | 'updated' | 'time' + selectedTimerOption?: number + presets: any[] + rule: any[] +} + +export const AutomationStateSymbol: InjectionKey = + Symbol('AutomationState') diff --git a/frappe/Filter/utils.ts b/frappe/Filter/utils.ts index 4fa09c526..f21afa361 100644 --- a/frappe/Filter/utils.ts +++ b/frappe/Filter/utils.ts @@ -1,10 +1,10 @@ import { h } from 'vue' -import Select from '../../src/components/Select/Select.vue' -import TextInput from '../../src/components/TextInput/TextInput.vue' -import Rating from '../../src/components/Rating/Rating.vue' import DatePicker from '../../src/components/DatePicker/DatePicker.vue' import DateRangePicker from '../../src/components/DatePicker/DateRangePicker.vue' import DateTimePicker from '../../src/components/DatePicker/DateTimePicker.vue' +import Rating from '../../src/components/Rating/Rating.vue' +import Select from '../../src/components/Select/Select.vue' +import TextInput from '../../src/components/TextInput/TextInput.vue' import { Link } from '../Link' import type { Field, StateRow } from './types' @@ -142,7 +142,10 @@ export const getValueControl = (row: StateRow) => { if (typeSelect.includes(fieldType) || typeCheck.includes(fieldType)) { let _options = options || ['yes', 'no'] - return h(Select, { placeholder: 'Select Option', options: _options }) + return h(Select, { + placeholder: 'Select Option', + options: _options, + }) } if (typeLink.includes(fieldType)) { @@ -191,6 +194,9 @@ export const getDefaultOperator = (field: { fieldName: string }) => { const operators = getOperators(field) + if (typeString.includes(field.fieldType)) { + return 'like' + } return operators[0].value } diff --git a/frappe/Icons/ActionIcon.vue b/frappe/Icons/ActionIcon.vue new file mode 100644 index 000000000..6979ea61b --- /dev/null +++ b/frappe/Icons/ActionIcon.vue @@ -0,0 +1,14 @@ + diff --git a/frappe/Icons/AlignIcon.vue b/frappe/Icons/AlignIcon.vue new file mode 100644 index 000000000..2db66f0b2 --- /dev/null +++ b/frappe/Icons/AlignIcon.vue @@ -0,0 +1,14 @@ + diff --git a/frappe/Icons/BellIcon.vue b/frappe/Icons/BellIcon.vue new file mode 100644 index 000000000..a1b5c651a --- /dev/null +++ b/frappe/Icons/BellIcon.vue @@ -0,0 +1,14 @@ + diff --git a/frappe/Icons/ConditionIcon.vue b/frappe/Icons/ConditionIcon.vue new file mode 100644 index 000000000..e55fd00bd --- /dev/null +++ b/frappe/Icons/ConditionIcon.vue @@ -0,0 +1,14 @@ + diff --git a/frappe/Icons/EventIcon.vue b/frappe/Icons/EventIcon.vue new file mode 100644 index 000000000..3355d33e3 --- /dev/null +++ b/frappe/Icons/EventIcon.vue @@ -0,0 +1,14 @@ + diff --git a/frappe/Icons/FocusIcon.vue b/frappe/Icons/FocusIcon.vue new file mode 100644 index 000000000..4bf9e3d3a --- /dev/null +++ b/frappe/Icons/FocusIcon.vue @@ -0,0 +1,14 @@ + diff --git a/frappe/Icons/ScopeIcon.vue b/frappe/Icons/ScopeIcon.vue new file mode 100644 index 000000000..8c6993dc4 --- /dev/null +++ b/frappe/Icons/ScopeIcon.vue @@ -0,0 +1,14 @@ + diff --git a/frappe/Icons/TimerIcon.vue b/frappe/Icons/TimerIcon.vue new file mode 100644 index 000000000..cac187013 --- /dev/null +++ b/frappe/Icons/TimerIcon.vue @@ -0,0 +1,14 @@ + diff --git a/frappe/index.d.ts b/frappe/index.d.ts index f66374763..cef489899 100644 --- a/frappe/index.d.ts +++ b/frappe/index.d.ts @@ -50,6 +50,7 @@ declare module 'frappe-ui/frappe' { // Components export const Link: Component export type { LinkProps } from './Link/types' + export const AutomationRule: Component } // Data Import diff --git a/frappe/index.js b/frappe/index.js index c617da846..52cf6e9eb 100644 --- a/frappe/index.js +++ b/frappe/index.js @@ -19,6 +19,9 @@ export { default as SignupBanner } from './Billing/SignupBanner.vue' // data import components export { default as DataImport } from './DataImport/DataImport.vue' +// automation rule components +export { default as AutomationRule } from './AutomationRule/AutomationLayout.vue' + // composables export { useOnboarding } from './Onboarding/onboarding.js' diff --git a/icons/AutomationIcon.vue b/icons/AutomationIcon.vue new file mode 100644 index 000000000..47fe00720 --- /dev/null +++ b/icons/AutomationIcon.vue @@ -0,0 +1,14 @@ + diff --git a/icons/index.ts b/icons/index.ts index a49329e14..81dcea956 100644 --- a/icons/index.ts +++ b/icons/index.ts @@ -3,6 +3,7 @@ export { default as DownSolidIcon } from './DownSolidIcon.vue' export { default as GreenCheckIcon } from './GreenCheckIcon.vue' // Frappe Icons +export { default as AutomationIcon } from './AutomationIcon.vue' export { default as HelpIcon } from './HelpIcon.vue' export { default as LightningIcon } from './LightningIcon.vue' export { default as MaximizeIcon } from './MaximizeIcon.vue' diff --git a/src/components/SettingsLayoutBase.vue b/src/components/SettingsLayoutBase.vue new file mode 100644 index 000000000..54b295a70 --- /dev/null +++ b/src/components/SettingsLayoutBase.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/data-fetching/index.ts b/src/data-fetching/index.ts index dc4f19b84..cb4da0072 100644 --- a/src/data-fetching/index.ts +++ b/src/data-fetching/index.ts @@ -2,6 +2,7 @@ export * from './useCall/types' export { useCall } from './useCall/useCall' export { useDoc } from './useDoc/useDoc' export { useDoctype } from './useDoctype/useDoctype' +export { useDoctypeMeta } from './useDoctypeMeta' export { useFrappeFetch } from './useFrappeFetch' export * from './useList/types' export { useList } from './useList/useList' diff --git a/src/data-fetching/useDoctypeMeta.ts b/src/data-fetching/useDoctypeMeta.ts new file mode 100644 index 000000000..b2e840ed7 --- /dev/null +++ b/src/data-fetching/useDoctypeMeta.ts @@ -0,0 +1,105 @@ +import { computed, reactive } from 'vue' +import { createResource } from '../resources' + +interface DocField { + fieldname: string + fieldtype: string + label?: string + options?: string + [key: string]: any +} + +interface DoctypeMeta { + name: string + fields: DocField[] + [key: string]: any +} + +export interface TransformedField { + label: string | undefined + type: string + value: string + options: string[] | undefined | null | string +} + +// Global cache for doctype meta +const metaCache = reactive>({}) + +const EXCLUDED_FIELDTYPES = [ + 'Section Break', + 'Read Only', + 'Column Break', + 'Tab Break', +] + +export function useDoctypeMeta(doctype: string) { + // Create resource for fetching meta + const resource = createResource({ + url: 'frappe.desk.form.load.getdoctype', + cache: ['DoctypeMeta', doctype], + params: { doctype }, + onSuccess: (response: any) => { + const docs = response.docs || [] + for (const doc of docs) { + metaCache[doc.name] = doc + } + }, + }) + + // Always return meta from cache (reactive) + const meta = computed(() => metaCache[doctype] || null) + if (!meta.value && !resource.loading) { + resource.fetch() + } + + // Computed for transformed fields + const fields = computed(() => { + const doctypeMeta = metaCache[doctype] + if (!doctypeMeta?.fields) return [] + + return doctypeMeta.fields + .map((f) => ({ + label: f.label, + type: f.fieldtype, + value: f.fieldname, + options: f.fieldtype === 'Select' ? f.options?.split('\n') : f.options, + })) + .filter((f) => !EXCLUDED_FIELDTYPES.includes(f.type)) + }) + + // Get a single field by fieldname (raw) + function getField(fieldname: string): DocField | null { + const doctypeMeta = metaCache[doctype] + return doctypeMeta?.fields.find((f) => f.fieldname === fieldname) || null + } + + const emailFields = computed(() => { + if (!fields.value.length) return [] + const recipients = fields.value.filter( + (f) => f.type === 'Data' && f.options === 'Email', + ) + return [ + { + label: 'Owner', + value: 'owner', + type: 'Data', + options: null, + }, + ...recipients, + { + label: 'Assignee', + value: '_assign', + type: 'JSON', + options: [], + }, + ] + }) + + return { + meta, + fields, + getField, + resource, + emailFields, + } +}