fix(dui): prevents empty publish selection state

fix(dui): prevents empty publish selection state
This commit is contained in:
Björn Steinhagen
2026-03-03 08:29:40 +02:00
committed by GitHub
4 changed files with 109 additions and 16 deletions
+19 -8
View File
@@ -32,13 +32,18 @@
<FilterListSelect :filter="modelCard.sendFilter" @update:filter="updateFilter" /> <FilterListSelect :filter="modelCard.sendFilter" @update:filter="updateFilter" />
<div class="mt-4 flex justify-end items-center space-x-2"> <div class="mt-4 flex justify-end items-center space-x-2">
<FormButton size="sm" color="outline" @click.stop="saveFilter()"> <FormButton
size="sm"
color="outline"
:disabled="isSaveDisabled"
@click.stop="saveFilter()"
>
Save Save
</FormButton> </FormButton>
<div v-tippy="!canCreateVersionPerm ? canCreateVersionMessage : ''"> <div v-tippy="!canCreateVersionPerm ? canCreateVersionMessage : ''">
<FormButton <FormButton
size="sm" size="sm"
:disabled="!canCreateVersionPerm" :disabled="!canCreateVersionPerm || isSaveDisabled"
@click.stop="saveFilterAndSend()" @click.stop="saveFilterAndSend()"
> >
Save & Publish Save & Publish
@@ -114,7 +119,7 @@
</ModelCardBase> </ModelCardBase>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, computed } from 'vue'
import ModelCardBase from '~/components/model/CardBase.vue' import ModelCardBase from '~/components/model/CardBase.vue'
import { Square3Stack3DIcon } from '@heroicons/vue/20/solid' import { Square3Stack3DIcon } from '@heroicons/vue/20/solid'
import type { ModelCardNotification } from '~/lib/models/card/notification' import type { ModelCardNotification } from '~/lib/models/card/notification'
@@ -206,22 +211,28 @@ const sendOrCancel = () => {
hasSetVersionMessage.value = false hasSetVersionMessage.value = false
} }
let newFilter: ISendFilter const newFilter = ref<ISendFilter>()
const updateFilter = (filter: ISendFilter) => { const updateFilter = (filter: ISendFilter) => {
newFilter = filter newFilter.value = filter
} }
const isSaveDisabled = computed(() => {
const filterToCheck = newFilter.value || props.modelCard.sendFilter
return !store.validateSendFilter(filterToCheck).valid
})
const saveFilter = async () => { const saveFilter = async () => {
if (!newFilter.value) return // Safety check
void trackEvent('DUI3 Action', { void trackEvent('DUI3 Action', {
name: 'Publish Card Filter Change', name: 'Publish Card Filter Change',
filter: newFilter.typeDiscriminator filter: newFilter.value.typeDiscriminator
}) })
// do not reset idmap while creating a new one because it is managed by host app // do not reset idmap while creating a new one because it is managed by host app
newFilter.idMap = props.modelCard.sendFilter?.idMap newFilter.value.idMap = props.modelCard.sendFilter?.idMap
await store.patchModel(props.modelCard.modelCardId, { await store.patchModel(props.modelCard.modelCardId, {
sendFilter: newFilter, sendFilter: newFilter.value,
expired: true expired: true
}) })
openFilterDialog.value = false openFilterDialog.value = false
+21 -7
View File
@@ -42,13 +42,10 @@
} }
" "
/> />
<div <div v-tippy="publishTooltipMessage" class="mt-2">
v-tippy="!canPublish && !isLoadingPermissions ? publishLimitMessage : ''"
class="mt-2"
>
<FormButton <FormButton
full-width full-width
:disabled="!canPublish || isLoadingPermissions" :disabled="isPublishDisabled"
:loading="isLoadingPermissions" :loading="isLoadingPermissions"
@click="addModel" @click="addModel"
> >
@@ -72,6 +69,7 @@ import type { ISendFilter } from '~/lib/models/card/send'
import { SenderModelCard } from '~/lib/models/card/send' import { SenderModelCard } from '~/lib/models/card/send'
import { useHostAppStore } from '~/store/hostApp' import { useHostAppStore } from '~/store/hostApp'
import { useAccountStore } from '~/store/accounts' import { useAccountStore } from '~/store/accounts'
import { useSelectionStore } from '~/store/selection'
import { useMixpanel } from '~/lib/core/composables/mixpanel' import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useSettingsTracking } from '~/lib/core/composables/trackSettings' import { useSettingsTracking } from '~/lib/core/composables/trackSettings'
import type { CardSetting } from '~/lib/models/card/setting' import type { CardSetting } from '~/lib/models/card/setting'
@@ -103,6 +101,23 @@ const { canCreateModelIngestion, canCreateVersion } = useCheckGraphql()
const canPublish = ref(false) const canPublish = ref(false)
const publishLimitMessage = ref<string | undefined>(undefined) const publishLimitMessage = ref<string | undefined>(undefined)
const isLoadingPermissions = ref(false) const isLoadingPermissions = ref(false)
const hostAppStore = useHostAppStore()
const selectionStore = useSelectionStore()
const publishValidation = computed(() => hostAppStore.validateSendFilter(filter.value))
const isPublishDisabled = computed(() => {
return (
!canPublish.value || isLoadingPermissions.value || !publishValidation.value.valid
)
})
const publishTooltipMessage = computed(() => {
if (!publishValidation.value.valid) return publishValidation.value.reason
if (!canPublish.value && !isLoadingPermissions.value)
return publishLimitMessage.value || ''
return ''
})
const updateSearchText = (text: string | undefined) => { const updateSearchText = (text: string | undefined) => {
urlParseError.value = undefined urlParseError.value = undefined
@@ -119,6 +134,7 @@ watch(urlParsedData, (newVal) => {
watch(showSendDialog, (newVal) => { watch(showSendDialog, (newVal) => {
if (newVal) { if (newVal) {
urlParseError.value = undefined urlParseError.value = undefined
void selectionStore.refreshSelectionFromHostApp()
} }
}) })
@@ -204,8 +220,6 @@ const selectModel = (model: ModelListModelItemFragment) => {
void trackEvent('DUI3 Action', { name: 'Publish Wizard', step: 'model selected' }) void trackEvent('DUI3 Action', { name: 'Publish Wizard', step: 'model selected' })
} }
const hostAppStore = useHostAppStore()
// accountId, serverUrl, projectId, modelId, sendFilter, settings // accountId, serverUrl, projectId, modelId, sendFilter, settings
const addModel = async () => { const addModel = async () => {
void trackEvent('DUI3 Action', { void trackEvent('DUI3 Action', {
+57
View File
@@ -1,6 +1,24 @@
import { computed } from 'vue'
import type {
ISendFilter,
SendFilterSelect,
RevitCategoriesSendFilter,
RevitViewsSendFilter
} from '~/lib/models/card/send'
import { ValidationHelpers } from '@speckle/ui-components' import { ValidationHelpers } from '@speckle/ui-components'
import type { GenericValidateFunction } from 'vee-validate' import type { GenericValidateFunction } from 'vee-validate'
export const isSelectFilter = (f: ISendFilter): f is SendFilterSelect =>
f.type === 'Select' || 'selectedItems' in f
export const isRevitCategoriesFilter = (
f: ISendFilter
): f is RevitCategoriesSendFilter =>
f.id === 'revitCategories' || f.id === 'archicadLayers'
export const isRevitViewsFilter = (f: ISendFilter): f is RevitViewsSendFilter =>
f.id === 'revitViews'
export const isEmail = ValidationHelpers.isEmail export const isEmail = ValidationHelpers.isEmail
export const isOneOrMultipleEmails = ValidationHelpers.isOneOrMultipleEmails export const isOneOrMultipleEmails = ValidationHelpers.isOneOrMultipleEmails
@@ -42,3 +60,42 @@ export function useModelNameValidationRules() {
isValidModelName isValidModelName
]) ])
} }
export type FilterValidationResult = { valid: boolean; reason?: string }
export function validateFilter(
filter: ISendFilter | undefined,
context: { selectionCount: number }
): FilterValidationResult {
if (!filter) return { valid: false, reason: 'No filter selected' }
// Selection Filter check
if (filter.name === 'Selection' || filter.id === 'selection') {
return context.selectionCount > 0
? { valid: true }
: { valid: false, reason: 'No objects selected to publish' }
}
// List-based filters (Rhino Layers, etc.)
if (isSelectFilter(filter)) {
return (filter.selectedItems?.length ?? 0) > 0
? { valid: true }
: { valid: false, reason: 'No items selected to publish' }
}
// Category-based filters
if (isRevitCategoriesFilter(filter)) {
return (filter.selectedCategories?.length ?? 0) > 0
? { valid: true }
: { valid: false, reason: 'No categories selected to publish' }
}
// View-based filters
if (isRevitViewsFilter(filter)) {
return filter.selectedView?.trim()
? { valid: true }
: { valid: false, reason: 'No view selected to publish' }
}
return { valid: true }
}
+12 -1
View File
@@ -13,6 +13,8 @@ import type {
RevitViewsSendFilter, RevitViewsSendFilter,
SendFilterSelect SendFilterSelect
} from '~/lib/models/card/send' } from '~/lib/models/card/send'
import { useSelectionStore } from '~/store/selection'
import { validateFilter } from '~/lib/validation'
import type { ToastNotification } from '@speckle/ui-components' import type { ToastNotification } from '@speckle/ui-components'
import { ToastNotificationType } from '@speckle/ui-components' import { ToastNotificationType } from '@speckle/ui-components'
import type { Nullable } from '@speckle/shared' import type { Nullable } from '@speckle/shared'
@@ -368,6 +370,14 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
*/ */
app.$sendBinding?.on('refreshSendFilters', () => void refreshSendFilters()) app.$sendBinding?.on('refreshSendFilters', () => void refreshSendFilters())
const validateSendFilter = (filter?: ISendFilter) => {
const selectionStore = useSelectionStore()
return validateFilter(filter, {
selectionCount: selectionStore.selectionInfo.selectedObjectIds?.length ?? 0
})
}
/** /**
* Send functionality * Send functionality
*/ */
@@ -938,6 +948,7 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
getSendSettings, getSendSettings,
setModelSendResult, setModelSendResult,
setModelReceiveResult, setModelReceiveResult,
handleModelProgressEvents handleModelProgressEvents,
validateSendFilter
} }
}) })