Skip to content

[Feat, Enhancement] Modal windows management system #915

@yermek-coder

Description

@yermek-coder

Summary

Improve modularity, readability of logic related to modal windows

Motivation

Current modal windows management have significant flaws:

  1. Tight Coupling: Dialog components are tightly coupled to their parent components, violating separation of concerns and making components harder to test and reuse
  2. Boilerplate Overhead: Each dialog requires:
  • A boolean flag in state
  • Mutations/actions to toggle the flag
  • Template code to mount the component
  • Event handlers for dialog results
  • This boilerplate is duplicated across every dialog usage
  1. Poor Scalability: As the number of dialogs grows, the parent component becomes bloated with dialog-related logic, flags, and imports
  2. Difficult State Management: Passing data to and from dialogs requires manual prop drilling and event handling, making data flow unclear
  3. Testing Complexity: tests of parent component and dialog are tightly coupled

Benefits of New System:

  1. Cleaner API: Open dialogs with a single async function call and receive results via Promise
  2. Better Separation: Dialog logic is decoupled from component logic
  3. Consistent code base: All dialogs follow the same patterns for opening, closing, and returning results

Detailed description

I want to suggest a well-tested dialog system for managing modal windows, which I used in a real project.
Using this system, we can call a dialog and receive an asynchronous result—no direct mounting or flag variables needed.

Example of chat start dialog management and possible improvement:
The dialog is currently placed in src/components/Chat/Chats.vue. It's mounted in the template itself and has an "isShowChatStartDialog" flag, a prop, and two events. The flag is stored in the Vuex store which has a variable and setter function.

   <chat-start-dialog
      v-model="isShowChatStartDialog"
      :partner-id="partnerId"
      @error="onError"
      @start-chat="openChat"
    />

const chatStateStore = useChatStateStore()
const { setShowChatStartDialog } = chatStateStore
const isShowChatStartDialog = computed({
  get: () => chatStateStore.isShowChatStartDialog,
  set: (value) => setShowChatStartDialog(value)
})

const openChat = (
  partnerId: string,
  messageText?: string,
  partnerName?: string,
  retrieveKey = false
) => {
  if (retrieveKey) {
    store.commit('chat/addNewChat', { partnerId, partnerName })
  }

  router.push({
    name: 'Chat',
    params: { partnerId },
    query: { messageText }
  })
}

With new system we reduce logic related to this dialog.

const openChatCreateDialog = async () => {
  const result = await store.dispatch('modals/openModal', {
    component: 'ChatStartDialog',
    props: {
      partnerId: props.partnerId
    }
    // any number of useful options like persistent: true
  })

  if (result) {
    openChat(result)
  }
}

const openChat = ({ partnerId, messageText, partnerName, retrieveKey }) => {
  if (retrieveKey) {
    store.commit('chat/addNewChat', { partnerId, partnerName })
  }

  router.push({
    name: 'Chat',
    params: { partnerId },
    query: { messageText }
  })
}

Here I moved error management into dialog itself for cleaner look, and also openChat receives arguments as object

Screenshots or videos

No response

Alternatives

No response

Proposed technical implementation

Code is not fully done but shows the general idea, there is some details to be discussed. I need confirmation of the usefulness of this system before continuing.

Basic idea is that we have a list of open modals in store. we can call function in the store to open new dialog, give it component name, props etc. New dialog item will be added to the store that will be displayed in the top level Modals component. Open dialog function returns new promise, we store resolve function of that promise in the store and call it when modal closes.

Store

export default {
    state: {
        modals: []
    },
    
    mutations: {
        ADD_MODAL(state, modal) {
            state.modals.push(modal)
        },
        
        REMOVE_MODAL(state, modalId) {
            const index = state.modals.findIndex(modal => modal.id === modalId)
            if (index !== -1) {
                state.modals.splice(index, 1)
            }
        }
    },
    
    actions: {
        openModal({ commit }, options) {
            const modal = {
                ...options,
                id: new Date().getTime()
            }
            
            return new Promise(resolve => {
                modal.$resolve = resolve
                commit('ADD_MODAL', modal)
            })
        },
        
        closeModal({ commit, state }, { spec, result }) {
            const modalId = typeof spec === 'object' ? spec.id : spec
            const modal = state.modals.find(m => m.id === modalId)
            
            if (modal) {
                commit('REMOVE_MODAL', modalId)
                modal.$resolve(result)
            }
        }
    },
    
    getters: {
        modals: state => state.modals
    }
}

High level modals component

<template>
  <div class="modals">
    <Modal v-for="modal in modals" :key="modal.id" :modal="modal"></Modal>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'
import Modal from './Modal.vue'

const store = useStore()
const modals = computed(() => store.getters['modals/modals'])
</script>

Modal component

<template>
  <VDialog @input="close" :modelValue="open" v-bind="modal.props">
    <component
      :is="modal.component"
      @close="close"
      @dismiss="close"
      v-bind="modal.props"
    />
  </VDialog>
</template>

<script setup>
import { ref } from 'vue'
import { useStore } from 'vuex'

const props = defineProps(['modal'])
const store = useStore()
const open = ref(true)

function close(result) {
  store.dispatch('modals/closeModal', { spec: props.modal, result })
}
</script>

When dialog component closes it emits 'close' or 'dismiss' event. Dismiss event fired when user closes dialog without any result, maybe by pressing close button. Close event usually fired with argument that is resolved by promise which returns by store.dispatch('modals/openModal') function.

Use of <component> requires registering of all dialogs as global component, or manually importing them into Modals component. For global dialog components registering we can use import.meta.glob.

I prefer wrapping store store.dispatch('modals/openModal') in some kind of abstraction but it's a detail to discuss

Metadata

Metadata

Assignees

No one assigned

    Labels

    UX/UIUser interface and experience improvementsVueFrontend features or fixes primarily in Vue.jsWebIssues specific to web client appenhancementNew feature or request

    Type

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions