Xây dựng chức năng xác thực bắt buộc cho các node trong biểu đồ luồng Vue 3

Tổng quan

Bài viết này trình bày cách triển khai chức năng xác thực các trường bắt buộc cho từng node trong một ứng dụng biểu đồ luồng được xây dựng bằng Vue 3. Chúng ta sẽ đi sâu vào các thành phần chính, bao gồm component cha quản lý dữ liệu và nút hoàn thành, component con hiển thị các loại node khác nhau (hành động và mẫu), và file cấu hình logic. Mục tiêu là đảm bảo tất cả các trường được đánh dấu là bắt buộc phải có giá trị trước khi người dùng có thể hoàn thành quy trình.

Cấu trúc dự án

Dự án bao gồm các file sau:

  • HomeView.vue: Component chính, chứa logic điều khiển luồng, nút "Hoàn thành" và hiển thị component FlowNode ban đầu.
  • FlowNode.vue: Component đệ quy để render các node trong biểu đồ luồng. Nó có thể render node hành động hoặc node mẫu thông qua component PatternNode. Component này cũng chịu trách nhiệm quản lý việc thêm/xóa node và gọi chức năng xác thực của các node con.
  • PatternNode.vue: Component render các node loại "mẫu", nơi người dùng nhập các giá trị cho điều kiện so sánh. Component này chứa logic xác thực cho các trường bắt buộc của nó.
  • rule-node.js: File chứa dữ liệu mẫu cho biểu đồ luồng và các hàm tiện ích để thao tác với dữ liệu node (thêm, xóa, tìm kiếm).

Triển khai

1. HomeView.vue - Quản lý luồng chính và xác thực

Component này khởi tạo dữ liệu biểu đồ luồng và có một nút "Hoàn thành". Khi người dùng nhấp vào nút này:

  • Biến showAllErrors được đặt thành true để kích hoạt hiển thị thông báo lỗi trên tất cả các node.
  • Hàm validateAll() trên flowNodeRef (tham chiếu đến FlowNode gốc) được gọi để bắt đầu quá trình xác thực đệ quy.
  • Nếu xác thực thành công (validationResult.allValid là true), dữ liệu sẽ được ghi log.
  • Nếu có lỗi, số lượng lỗi sẽ được tính và hiển thị.
<template>
  <div class="flowchart-app">
    <div>Hiển thị biểu đồ luồng</div>
    <el-button type="primary" @click="handleFinish">Hoàn thành</el-button>
    <div class="flowchart-container">
      <FlowNode ref="flowNodeRef" :node="flowData[0]" @update="handleUpdate" />
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { flowData } from './rule-node.js'
import FlowNode from './FlowNode.vue'

const flowNodeRef = ref(null)
const showAllErrors = ref(false) // Cờ để kích hoạt hiển thị lỗi toàn cục

const handleFinish = async () => {
  showAllErrors.value = true // Bật hiển thị lỗi cho tất cả các node
  const validationResult = flowNodeRef.value?.validateAll() // Gọi hàm xác thực đệ quy

  if (validationResult?.allValid) {
    console.log('Dữ liệu gửi đi:', flowData.value)
  } else {
    // Đếm số trường không hợp lệ
    const errorCount = validationResult.invalidNodes.reduce((acc, node) => acc + node.invalidFields.length, 0)
    console.log(`Có ${errorCount} mục bắt buộc chưa được điền.`)
  }
}

// Hàm này có thể dùng để cập nhật lại UI khi cấu trúc dữ liệu thay đổi, ví dụ sau khi thêm/xóa node
const handleUpdate = () => {
  // Cần làm gì đó ở đây nếu cần, ví dụ cập nhật state
}
</script>

<style>
.flowchart-app {
  font-family: Arial, sans-serif;
  padding: 20px;
  max-width: 1000px;
  margin: 0 auto;
}

.flowchart-container {
  margin-top: 20px;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
  background: #f9f9f9;
}
</style>

2. FlowNode.vue - Render node và quản lý đệ quy

Component này đóng vai trò là bộ điều phối cho từng node trong biểu đồ:

  • Nó render một node "hành động" (action-node-box) nếu node.type không phải là 'PATTERN'. Node hành động cho phép chọn loại logic (AND/OR) và thêm các node con.
  • Nó render component PatternNode nếu node.type là 'PATTERN'.
  • Nó xử lý việc hiển thị các node con (childList) một cách đệ quy.
  • Quan trọng nhất, nó cung cấp phương thức validateAll() được định nghĩa bằng defineExpose. Phương thức này:
    • Xác thực node hiện tại (nếu là loại PATTERN, nó gọi validate() của PatternNode).
    • Thu thập các lỗi từ các node con bằng cách lặp qua childNodeRefs và gọi đệ quy validateAll() trên chúng.
    • Trả về một đối tượng chứa trạng thái hợp lệ tổng thể (allValid) và một danh sách các node không hợp lệ cùng với các trường lỗi cụ thể (invalidNodes).
<template>
  <div class="node-wrapper">
    <div class="node-container">
      <!-- Node hành động -->
      <div v-if="isTypeA" class="action-node-box">
        <div class="action-node-container">
          <el-icon><Share /></el-icon>
          <el-select
            v-model="node.type"
            placeholder="Chọn"
            size="small"
            style="width: 60px"
          >
            <el-option
              v-for="item in selectOptions"
              :key="item.value"
              :label="item.label"
              :value="item.value"
            />
          </el-select>
        </div>
        <!-- Icon menu -->
        <el-popover placement="right-start" :width="100">
          <template #reference>
            <el-icon class="set-menu-icon">
              <Tools />
            </el-icon>
          </template>
          <div class="menu-item" @click="handleAddAction">Thêm hành động</div>
          <div class="menu-item" @click="handleAddDecision">Thêm node</div>
          <div class="menu-item" v-if="isNested" @click="handleDelete()">
            Xóa
          </div>
        </el-popover>
      </div>

      <!-- Node mẫu -->
      <PatternNode
        v-else
        ref="patternNodeRef"
        :node="node"
        :parent-node="parentNode"
        :show-all-errors="showAllErrors"
        @update="$emit('update')"
      />

      <div v-if="hasChildList" class="childList-connector"></div>
    </div>
    <div v-if="hasChildList" class="childList-container">
      <div
        :style="{ height: 65 * (node.childList.length - 1) + 'px' }"
        class="vertical-line"
      ></div>
      <div class="childList-nodes">
        <FlowNode
          v-for="(child, index) in node.childList"
          :key="index"
          :ref="(el) => (childNodeRefs[index] = el)"
          :node="child"
          :parent-node="node"
          :show-all-errors="showAllErrors"
          @update="$emit('update')"
        />
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed, ref } from 'vue'
import { useRuleNode, selectOptions } from './rule-node.js'
import PatternNode from './PatternNode.vue'

const props = defineProps({
  isNested: {
    type: Boolean,
    default: false,
  },
  node: {
    type: Object,
    required: true,
  },
  isLast: {
    type: Boolean,
    default: false,
  },
  parentNode: {
    type: Object,
    default: null,
  },
  index: {
    type: Number,
    default: -1,
  },
  showAllErrors: { // Truyền xuống để PatternNode biết khi nào hiển thị lỗi
    type: Boolean,
    default: false,
  },
})

const emit = defineEmits(['update'])

const { addActionNode, addDecisionNode } = useRuleNode()

const isTypeA = computed(() => props.node.type !== 'PATTERN')
const hasChildList = computed(() => props.node.childList?.length > 0)

const handleAddAction = () => {
  addActionNode(props.node)
  emit('update')
}
const handleAddDecision = () => {
  addDecisionNode(props.node)
  emit('update')
}

const handleDelete = () => {
  if (props.parentNode && typeof props.index === 'number') {
    props.parentNode.childList.splice(props.index, 1)
    emit('update')
  }
}

const patternNodeRef = ref(null)
const childNodeRefs = ref([]) // Mảng để lưu trữ tham chiếu đến các node con

defineExpose({
  validateAll: () => {
    let allValid = true
    const invalidNodes = []

    // Xác thực node hiện tại (nếu là loại PATTERN)
    if (props.node.type === 'PATTERN') {
      const isValid = patternNodeRef.value?.validate() ?? false
      if (!isValid) {
        allValid = false
        invalidNodes.push({
          node: props.node,
          invalidFields: patternNodeRef.value?.getInvalidFields() || []
        })
      }
    }

    // Xác thực đệ quy các node con
    childNodeRefs.value.forEach(childRef => {
      const result = childRef?.validateAll()
      if (!result.allValid) {
        allValid = false
        invalidNodes.push(...result.invalidNodes) // Gộp lỗi từ các node con
      }
    })

    return { allValid, invalidNodes }
  }
})
</script>
<style scoped lang="less">
/* Các style cho node và kết nối */
.node-wrapper {
  display: flex;
  align-items: flex-start;
  margin-bottom: 20px;
  &:last-child {
    margin-bottom: 0px;
  }
}

.node-container {
  display: flex;
  align-items: center;
  position: relative;
}

.action-node-container {
  padding: 10px 15px;
  border-radius: 20px;
  min-width: 120px;
  text-align: center;
  background-color: #4caf50;
  color: white;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
  position: relative;
  z-index: 2;
  display: inline-flex;
  align-items: center;

  .node-label {
    font-weight: 500;
  }

  .node-value {
    margin-left: 4px;
    opacity: 0.9;
  }
}

.childList-connector {
  width: 20px;
  height: 2px;
  background: #999;
  margin-left: -1px;
  z-index: 1;
  margin-top: 2px;
}

.childList-container {
  display: flex;
}

.vertical-line {
  width: 2px;
  background: #999;
  margin-right: 18px;
  margin-top: 22px;
}

.childList-nodes {
  display: flex;
  flex-direction: column;
  flex-grow: 1;
}
.node-wrapper {
  margin-bottom: 24px; /* Tăng khoảng cách cho lỗi */
}

.childList-nodes {
  margin-top: 8px;
}
/* Kết nối ngang cho node con */
.childList-nodes > .node-wrapper > .node-container::before {
  content: '';
  position: absolute;
  left: -20px;
  top: 50%;
  width: 20px;
  height: 2px;
  background: #999;
  z-index: 1;
}

/* Rút ngắn đường nối dọc cho node con cuối */
.childList-nodes
  > .node-wrapper:last-child
  > .childList-container
  > .vertical-line {
  min-height: 22px;
}
.action-node-box {
  position: relative;
  .set-menu-icon {
    position: absolute;
    margin-left: 8px;
    top: 16px;
    z-index: 9;
  }
}
</style>

3. PatternNode.vue - Xác thực các trường mẫu

Component này chịu trách nhiệm hiển thị và xác thực các trường của một node mẫu:

  • Render các trường el-selectel-input cho leftValue, rightSource, và rightValue.
  • Hiển thị thông báo lỗi "Bắt buộc" bên dưới mỗi trường nếu nó trống và showAllErrors (từ prop) hoặc localShowError là true.
  • Cung cấp phương thức validate() được định nghĩa bằng defineExpose. Phương thức này:
    • Đặt localShowError thành true để kích hoạt hiển thị lỗi cục bộ.
    • Trả về true nếu tất cả các trường bắt buộc đều có giá trị, false nếu ngược lại.
  • Cung cấp phương thức getInvalidFields() để liệt kê tên các trường chưa được điền.
<template>
  <div class="pattern-node-box">
    <div class="pattern-node-container">
      <!-- Giá trị bên trái -->
      <div class="form-item">
        <el-select
          style="width: 240px"
          v-model="node.pattern.leftValue"
          placeholder="Chọn giá trị bên trái"
        >
          <el-option v-for="item in options" :key="item" :value="item" />
        </el-select>
        <div
          v-if="(localShowError || showAllErrors) && !node.pattern.leftValue"
          class="error-message"
        >
          Bắt buộc
        </div>
      </div>

      <!-- Nguồn giá trị bên phải -->
      <div class="form-item">
        <el-select
          v-model="node.pattern.rightSource"
          placeholder="Chọn nguồn giá trị bên phải"
          style="width: 240px"
        >
          <el-option
            v-for="item in rightSourceOptions"
            :key="item.value"
            :label="item.label"
            :value="item.value"
          />
        </el-select>
        <div
          v-if="(localShowError || showAllErrors) && !node.pattern.rightSource"
          class="error-message"
        >
          Bắt buộc
        </div>
      </div>

      <!-- Giá trị bên phải -->
      <div class="form-item">
        <el-select
          v-if="node.pattern.rightSource == 'Main'"
          v-model="node.pattern.rightValue"
          placeholder="Chọn giá trị bên phải"
          style="width: 240px"
        >
          <el-option v-for="item in options" :key="item" :value="item" />
        </el-select>
        <el-input
          v-if="node.pattern.rightSource == 'Custom'"
          v-model="node.pattern.rightValue"
          placeholder="Nhập giá trị bên phải"
          style="width: 240px"
        />
        <div
          v-if="(localShowError || showAllErrors) && !node.pattern.rightValue"
          class="error-message"
        >
          Bắt buộc
        </div>
      </div>
    </div>
    <!-- Icon menu -->
    <el-popover placement="right-start" :width="80">
      <template #reference>
        <el-icon class="set-menu-icon">
          <Tools />
        </el-icon>
      </template>
      <div class="menu-item" @click="handleDelete">Xóa</div>
    </el-popover>
  </div>
</template>

<script setup>
import { ref, computed, defineExpose } from 'vue'

const props = defineProps({
  parentNode: {
    type: Object,
    default: null,
  },
  node: {
    type: Object,
    required: true,
  },
  showAllErrors: { // Nhận cờ hiển thị lỗi từ cha
    type: Boolean,
    default: false,
  },
})

const emit = defineEmits(['update']) // Phát sự kiện cập nhật
const localShowError = ref(false) // Trạng thái lỗi cục bộ

// Kiểm tra tính hợp lệ của các trường bắt buộc
const isValid = computed(() => {
  return (
    props.node.pattern.leftValue &&
    props.node.pattern.rightSource &&
    props.node.pattern.rightValue
  )
})

const rightSourceOptions = ref([
  {
    label: 'Cột dữ liệu',
    value: 'Main',
  },
  {
    label: 'Nhập thủ công',
    value: 'Custom',
  },
])
const options = ref(['giá trị 1', 'giá trị 2', 'giá trị 3', 'giá trị 4'])

const handleChange = () => {
  emit('validate', isValid.value) // Phát sự kiện khi có thay đổi (ít dùng ở đây)
}

defineExpose({
  validate: () => {
    localShowError.value = true // Luôn hiển thị lỗi khi gọi validate
    return isValid.value
  },
  getInvalidFields: () => {
    const invalidFields = []
    if (!props.node.pattern.leftValue) invalidFields.push('Giá trị bên trái')
    if (!props.node.pattern.rightSource) invalidFields.push('Nguồn giá trị bên phải')
    if (!props.node.pattern.rightValue) invalidFields.push('Giá trị bên phải')
    return invalidFields
  },
})

// Xóa node hiện tại
const handleDelete = () => {
  const index = props.parentNode.childList.findIndex(
    (item) => item === props.node
  )
  if (index !== -1) {
    props.parentNode.childList.splice(index, 1)
    emit('update') // Phát sự kiện cập nhật để cha xử lý
  } else {
    console.warn('Không tìm thấy node để xóa')
  }
}
</script>

<style scoped lang="less">
.pattern-node-box {
  display: inline-flex;
  align-items: center;
  .set-menu-icon {
    margin-left: 8px;
  }
}
.pattern-node-container {
  padding: 10px 15px;
  border-radius: 20px;
  min-width: 120px;
  text-align: center;
  background-color: #2196f3; /* Màu xanh cho node mẫu */
  color: white;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
  position: relative;
  z-index: 2;
  display: inline-flex;
  align-items: center;

  .node-label {
    font-weight: 500;
  }

  .node-value {
    margin-left: 4px;
  }
}
/* Style chi tiết hơn cho container node mẫu */
.pattern-node-container {
  display: flex;
  gap: 10px; /* Khoảng cách giữa các trường */
  padding: 10px;
  background-color: #f5f7fa; /* Màu nền nhẹ */
  border-radius: 4px;
  color: #333; /* Màu chữ mặc định */
}

.form-item {
  position: relative; /* Để định vị thông báo lỗi */
}

.error-message {
  color: #f56c6c; /* Màu đỏ cho lỗi */
  font-size: 12px;
  position: absolute;
  bottom: -18px; /* Đặt dưới input */
  left: 6px;
}
</style>

4. rule-node.js - Dữ liệu và logic tiện ích

File này chứa:

  • flowData: Một ref chứa cấu trúc dữ liệu ban đầu của biểu đồ luồng.
  • selectOptions: Dữ liệu tùy chọn cho dropdown chọn loại node (AND/OR).
  • useRuleNode: Một hook tùy chỉnh cung cấp các hàm để:
    • addActionNode(parentNode, index): Thêm một node hành động mới vào childList của parentNode.
    • addDecisionNode(parentNode, index): Thêm một node mẫu mới vào childList của parentNode.
    • findNodeByPath(path): Tìm một node cụ thể dựa trên đường dẫn của nó (ít được sử dụng trong ví dụ này).
import { ref } from 'vue'

// Dữ liệu ban đầu cho biểu đồ luồng
export const flowData = ref([
  {
    type: 'AND', // Node điều kiện logic (AND/OR)
    childList: [
      {
        type: 'PATTERN', // Node mẫu với các điều kiện so sánh
        pattern: {
          leftValue: 'obj', // Giá trị bên trái
          leftSource: 'Main', // Nguồn giá trị bên trái (ví dụ: trường dữ liệu)
          rightSource: 'Main', // Nguồn giá trị bên phải
          rightValue: '', // Giá trị bên phải (đang trống, sẽ gây lỗi xác thực)
        },
      },
      {
        type: 'PATTERN',
        pattern: {
          leftValue: 'obj',
          leftSource: 'Main',
          rightSource: 'Main',
          rightValue: 'kks',
        },
      },
      {
        type: 'AND', // Node điều kiện lồng nhau
        childList: [
          {
            type: 'PATTERN',
            pattern: {
              leftValue: 'obj',
              leftSource: 'Main',
              rightSource: '', // Nguồn giá trị bên phải đang trống
              rightValue: '',
            },
          },
          {
            type: 'PATTERN',
            pattern: {
              leftValue: 'obj',
              leftSource: 'Main',
              rightSource: 'Main',
              rightValue: 'kks',
            },
          },
        ],
      },
    ],
  },
])
// Tùy chọn cho dropdown chọn loại node
export const selectOptions = ref([
  {
    value: 'AND',
    label: 'Và',
  },
  {
    value: 'OR',
    label: 'Hoặc',
  },
])
export const selectValue = ref('AND') // Giá trị mặc định cho dropdown

// Hàm tiện ích để thao tác với node
export const useRuleNode = () => {
  /**
   * Thêm node hành động
   * @param {Object} parentNode - Node cha
   * @param {Number} [index] - Vị trí chèn (mặc định là cuối)
   */
  const addActionNode = (parentNode, index) => {
    if (!parentNode.childList) {
      parentNode.childList = []
    }
    const newNode = {
      type: 'AND', // Node hành động mặc định là AND
      childList: [],
    }
    if (typeof index === 'number') {
      parentNode.childList.splice(index, 0, newNode)
    } else {
      parentNode.childList.push(newNode)
    }
    return newNode
  }

  /**
   * Thêm node quyết định (mẫu)
   * @param {Object} parentNode - Node cha
   * @param {Number} [index] - Vị trí chèn (mặc định là cuối)
   */
  const addDecisionNode = (parentNode, index) => {
    if (!parentNode.childList) {
      parentNode.childList = []
    }
    const newNode = {
      type: 'PATTERN', // Node mẫu
      pattern: {
        leftValue: '', // Các trường mặc định trống
        leftSource: 'Main',
        rightSource: '',
        rightValue: '',
      },
    }
    if (typeof index === 'number') {
      parentNode.childList.splice(index, 0, newNode)
    } else {
      parentNode.childList.push(newNode)
    }
    return newNode
  }

  /**
   * Tìm node theo đường dẫn
   * @param {Array} path - Mảng các chỉ số đường dẫn
   */
  const findNodeByPath = (path) => {
    let current = flowData.value
    for (const segment of path) {
      // Cần xử lý logic truy cập phần tử mảng hoặc thuộc tính object tùy theo cấu trúc
      // Ví dụ đơn giản: giả định path là mảng chỉ số cho childList
      if (Array.isArray(current)) {
         current = current[segment]
      } else if (current && typeof current === 'object' && current.childList) {
         current = current.childList[segment]
      } else {
         return null; // Không tìm thấy
      }
    }
    return current
  }

  return {
    addActionNode,
    addDecisionNode,
    findNodeByPath,
  }
}

Kết luận

Bằng cách sử dụng cơ chế xác thực đệ quy thông qua defineExpose và truyền xuống prop showAllErrors, chúng ta có thể triển khai một hệ thống xác thực mạnh mẽ cho các node trong biểu đồ luồng. Component cha (HomeView) chịu trách nhiệm kích hoạt quá trình và xử lý kết quả cuối cùng, trong khi các component con (FlowNodePatternNode) thực hiện logic xác thực cụ thể của chúng.

Thẻ: Vue.js Vue 3 Biểu đồ luồng Xác thực biểu mẫu Component đệ quy

Đăng vào ngày 8 tháng 8 lúc 01:03