fix(fe2): fixed model upload validation & bunch of other things (#4961)
* FE validation before model creation * minor fix * testing UI change in cardview * a bunch of fixes * table improvements * undid test thing
This commit is contained in:
committed by
GitHub
parent
64c87f787e
commit
5b7f28925c
@@ -109,7 +109,8 @@ const {
|
||||
panoramaPreviewUrl,
|
||||
shouldLoadPanorama,
|
||||
isLoadingPanorama,
|
||||
hasDoneFirstLoad
|
||||
hasDoneFirstLoad,
|
||||
isPanoramaPlaceholder
|
||||
} = usePreviewImageBlob(basePreviewUrl, { enabled: isInViewport })
|
||||
|
||||
const hovered = ref(false)
|
||||
@@ -152,10 +153,15 @@ const shouldShowMainPreview = computed(
|
||||
() =>
|
||||
(!hovered.value && finalPreviewUrl.value) ||
|
||||
isLoadingPanorama.value ||
|
||||
!props.panoramaOnHover
|
||||
!props.panoramaOnHover ||
|
||||
isPanoramaPlaceholder.value
|
||||
)
|
||||
const shouldShowPanoramicPreview = computed(
|
||||
() => hovered.value && panoramaPreviewUrl.value && props.panoramaOnHover
|
||||
() =>
|
||||
hovered.value &&
|
||||
panoramaPreviewUrl.value &&
|
||||
props.panoramaOnHover &&
|
||||
!isPanoramaPlaceholder.value
|
||||
)
|
||||
|
||||
onMounted(() => setParentDimensions())
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
<span>{{ errorMessage }}</span>
|
||||
</span>
|
||||
<div
|
||||
v-if="fileUpload.progress > 0"
|
||||
:class="[' w-full mt-2', progressBarClasses]"
|
||||
v-else
|
||||
:class="['w-full mt-2', progressBarClasses]"
|
||||
:style="progressBarStyle"
|
||||
/>
|
||||
</div>
|
||||
@@ -67,17 +67,14 @@
|
||||
<ProjectPageModelsNewDialog
|
||||
v-model:open="showNewModelDialog"
|
||||
:project-id="project.id"
|
||||
:model-name="selectedFile?.file.name"
|
||||
:model-name="fileUpload?.file.name"
|
||||
@submit="onModelCreate"
|
||||
/>
|
||||
</FormFileUploadZone>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useFileImport } from '~~/lib/core/composables/fileImport'
|
||||
import {
|
||||
useFileUploadProgressCore,
|
||||
type UploadableFileItem
|
||||
} from '~~/lib/form/composables/fileUpload'
|
||||
import { useFileUploadProgressCore } from '~~/lib/form/composables/fileUpload'
|
||||
import { ExclamationTriangleIcon } from '@heroicons/vue/24/solid'
|
||||
import { connectorsRoute } from '~/lib/common/helpers/route'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
@@ -87,6 +84,7 @@ import type {
|
||||
ProjectCardImportFileArea_ProjectFragment,
|
||||
ProjectPageLatestItemsModelItemFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import type { FileAreaUploadingPayload } from '~/lib/form/helpers/fileUpload'
|
||||
|
||||
type EmptyStateVariants = 'modelGrid' | 'modelList' | 'modelsSection'
|
||||
|
||||
@@ -115,6 +113,13 @@ graphql(`
|
||||
}
|
||||
`)
|
||||
|
||||
const emit = defineEmits<{
|
||||
/**
|
||||
* Emits when files start/finish uploading
|
||||
*/
|
||||
uploading: [payload: FileAreaUploadingPayload]
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
project: ProjectCardImportFileArea_ProjectFragment
|
||||
model?: ProjectCardImportFileArea_ModelFragment
|
||||
@@ -124,11 +129,28 @@ const props = defineProps<{
|
||||
|
||||
const {
|
||||
maxSizeInBytes,
|
||||
onFilesSelected: onFilesSelectedInternal,
|
||||
onFilesSelected,
|
||||
accept,
|
||||
upload: fileUpload,
|
||||
isUploading
|
||||
} = useFileImport(toRefs(props))
|
||||
isUploading,
|
||||
uploadSelected,
|
||||
resetSelected,
|
||||
isUploadable: isFileUploadUploadable
|
||||
} = useFileImport({
|
||||
...toRefs(props),
|
||||
manuallyTriggerUpload: true,
|
||||
fileSelectedCallback: () => {
|
||||
if (props.model) {
|
||||
// Uploading inside an existing model - trigger upload immediately
|
||||
uploadSelected()
|
||||
} else {
|
||||
if (!fileUpload.value?.error) {
|
||||
// Only if upload is valid, trigger model creation dialog
|
||||
showNewModelDialog.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { errorMessage, progressBarClasses, progressBarStyle } =
|
||||
useFileUploadProgressCore({
|
||||
@@ -140,17 +162,7 @@ const uploadZone = ref(
|
||||
triggerPicker: () => void
|
||||
}>
|
||||
)
|
||||
|
||||
const selectedFile = shallowRef<Nullable<UploadableFileItem>>(null)
|
||||
|
||||
const showNewModelDialog = computed({
|
||||
get: () => !!selectedFile.value,
|
||||
set: (newVal) => {
|
||||
if (!newVal) {
|
||||
selectedFile.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
const showNewModelDialog = ref(false)
|
||||
|
||||
const modelName = computed(() => props.modelName || props.model?.name)
|
||||
const accessCheck = computed(() => {
|
||||
@@ -196,7 +208,7 @@ const containerClasses = computed(() => {
|
||||
if (props.emptyStateVariant === 'modelGrid') {
|
||||
classParts.push('p-4 gap-4')
|
||||
} else if (props.emptyStateVariant === 'modelList') {
|
||||
classParts.push('p-4 gap-4 text-center')
|
||||
classParts.push('gap-4 text-center')
|
||||
} else if (props.emptyStateVariant === 'modelsSection') {
|
||||
classParts.push('p-4 gap-4 text-balance')
|
||||
} else {
|
||||
@@ -261,35 +273,37 @@ const getDashedBorderClasses = (isDraggingFiles: boolean) => {
|
||||
return 'border-outline-2'
|
||||
}
|
||||
|
||||
const onFilesSelected = (params: { files: UploadableFileItem[] }) => {
|
||||
const firstFile = params.files[0]
|
||||
if (!firstFile) return
|
||||
|
||||
if (props.model) {
|
||||
// Uploading version to specific model, trigger upload instantly
|
||||
onFilesSelectedInternal({ files: [firstFile] })
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise store selected file and show model create dialog
|
||||
selectedFile.value = firstFile
|
||||
}
|
||||
|
||||
const onModelCreate = (params: { model: ProjectPageLatestItemsModelItemFragment }) => {
|
||||
if (!selectedFile.value) return
|
||||
if (!isFileUploadUploadable.value) return
|
||||
|
||||
onFilesSelectedInternal({
|
||||
files: [selectedFile.value],
|
||||
uploadSelected({
|
||||
modelName: params.model.name
|
||||
})
|
||||
|
||||
selectedFile.value = null
|
||||
}
|
||||
|
||||
const triggerPicker = () => {
|
||||
uploadZone.value?.triggerPicker()
|
||||
}
|
||||
|
||||
watch(showNewModelDialog, (newVal, oldVal) => {
|
||||
if (oldVal && !newVal) {
|
||||
// Should we unselect file? Only if model was not created
|
||||
if (!isUploading.value) {
|
||||
resetSelected()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(isUploading, (newVal, oldVal) => {
|
||||
// fileUpload is always gonna be non-null when isUploading changes
|
||||
emit('uploading', { isUploading: newVal, upload: fileUpload.value! })
|
||||
|
||||
if (!newVal && oldVal) {
|
||||
// Reset file upload state when upload finishes
|
||||
resetSelected()
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
triggerPicker
|
||||
})
|
||||
|
||||
@@ -61,15 +61,15 @@
|
||||
<ProjectPendingFileImportStatus
|
||||
v-if="isPendingModelFragment(model)"
|
||||
:upload="model"
|
||||
class="px-4 w-full h-full"
|
||||
class="px-4 w-full h-48"
|
||||
/>
|
||||
<ProjectPendingFileImportStatus
|
||||
v-else-if="pendingVersion"
|
||||
:upload="pendingVersion"
|
||||
type="subversion"
|
||||
class="px-4 w-full text-foreground-2 text-sm flex flex-col items-center space-y-1"
|
||||
class="px-4 w-full h-48 text-foreground-2 text-sm flex flex-col items-center space-y-1"
|
||||
/>
|
||||
<template v-else-if="previewUrl">
|
||||
<template v-else-if="previewUrl && !isVersionUploading">
|
||||
<NuxtLink
|
||||
:to="!defaultLinkDisabled ? modelRoute(projectId, model.id) : undefined"
|
||||
class="relative z-20 bg-foundation-page w-full h-48 rounded-xl border border-outline-2"
|
||||
@@ -79,7 +79,7 @@
|
||||
</template>
|
||||
<div
|
||||
v-if="!isPendingModelFragment(model) && project"
|
||||
v-show="!previewUrl && !pendingVersion"
|
||||
v-show="!pendingVersion && (isVersionUploading || !previewUrl)"
|
||||
class="h-48 w-full relative z-30"
|
||||
>
|
||||
<ProjectCardImportFileArea
|
||||
@@ -88,6 +88,7 @@
|
||||
:project="project"
|
||||
:model="model"
|
||||
class="w-full h-full"
|
||||
@uploading="onVersionUploading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,6 +133,7 @@ import { modelVersionsRoute, modelRoute } from '~~/lib/common/helpers/route'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { isPendingModelFragment } from '~~/lib/projects/helpers/models'
|
||||
import type { Nullable, Optional } from '@speckle/shared'
|
||||
import type { FileAreaUploadingPayload } from '~/lib/form/helpers/fileUpload'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageModelsCardProject on Project {
|
||||
@@ -175,6 +177,8 @@ const importArea = ref(
|
||||
triggerPicker: () => void
|
||||
}>
|
||||
)
|
||||
|
||||
const isVersionUploading = ref(false)
|
||||
const showActionsMenu = ref(false)
|
||||
const hovered = ref(false)
|
||||
|
||||
@@ -233,6 +237,10 @@ const onCardClick = (event: KeyboardEvent | MouseEvent) => {
|
||||
emit('click', event)
|
||||
}
|
||||
|
||||
const onVersionUploading = (payload: FileAreaUploadingPayload) => {
|
||||
isVersionUploading.value = payload.isUploading
|
||||
}
|
||||
|
||||
const triggerVersionUpload = () => {
|
||||
importArea.value?.triggerPicker()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="itemsCount">
|
||||
<template v-if="itemsCount && !isModelUploading">
|
||||
<ProjectModelsBasicCardView
|
||||
:items="items"
|
||||
:project="project"
|
||||
@@ -27,6 +27,7 @@
|
||||
v-if="project"
|
||||
:project="project"
|
||||
class="h-36 col-span-4"
|
||||
@uploading="onModelUploading"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-center my-6">
|
||||
@@ -54,6 +55,7 @@ import {
|
||||
import type { Nullable, Optional, SourceAppDefinition } from '@speckle/shared'
|
||||
import type { InfiniteLoaderState } from '~~/lib/global/helpers/components'
|
||||
import { allProjectModelsRoute } from '~~/lib/common/helpers/route'
|
||||
import type { FileAreaUploadingPayload } from '~/lib/form/helpers/fileUpload'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:loading', v: boolean): void
|
||||
@@ -115,6 +117,7 @@ const latestModelsQueryVariables = computed(
|
||||
)
|
||||
|
||||
const infiniteLoaderId = ref('')
|
||||
const isModelUploading = ref(false)
|
||||
|
||||
// Base query (all pending uploads + first page of models)
|
||||
const {
|
||||
@@ -200,6 +203,10 @@ const calculateLoaderId = () => {
|
||||
infiniteLoaderId.value = id
|
||||
}
|
||||
|
||||
const onModelUploading = (payload: FileAreaUploadingPayload) => {
|
||||
isModelUploading.value = payload.isUploading
|
||||
}
|
||||
|
||||
watch(areQueriesLoading, (newVal) => {
|
||||
emit('update:loading', newVal)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="topLevelItems.length && project" class="space-y-2 max-w-full">
|
||||
<div
|
||||
v-if="topLevelItems.length && project && !isModelUploading"
|
||||
class="space-y-2 max-w-full"
|
||||
>
|
||||
<div v-for="item in topLevelItems" :key="item.id">
|
||||
<ProjectPageModelsStructureItem
|
||||
:item="item"
|
||||
@@ -29,6 +32,7 @@
|
||||
v-if="project"
|
||||
:project="project"
|
||||
class="h-36 col-span-4"
|
||||
@uploading="onModelUploading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,6 +65,7 @@ import type { Nullable, SourceAppDefinition } from '@speckle/shared'
|
||||
import type { InfiniteLoaderState } from '~~/lib/global/helpers/components'
|
||||
import { useEvictProjectModelFields } from '~~/lib/projects/composables/modelManagement'
|
||||
import { allProjectModelsRoute } from '~~/lib/common/helpers/route'
|
||||
import type { FileAreaUploadingPayload } from '~/lib/form/helpers/fileUpload'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:loading', v: boolean): void
|
||||
@@ -109,6 +114,7 @@ const baseQueryVariables = computed(
|
||||
)
|
||||
|
||||
const infiniteLoaderId = ref('')
|
||||
const isModelUploading = ref(false)
|
||||
|
||||
// Base query (all pending uploads + first page of models)
|
||||
const {
|
||||
@@ -209,6 +215,10 @@ const calculateLoaderId = () => {
|
||||
infiniteLoaderId.value = id
|
||||
}
|
||||
|
||||
const onModelUploading = (payload: FileAreaUploadingPayload) => {
|
||||
isModelUploading.value = payload.isUploading
|
||||
}
|
||||
|
||||
watch(areQueriesLoading, (newVal) => {
|
||||
emit('update:loading', newVal)
|
||||
})
|
||||
|
||||
@@ -47,38 +47,34 @@
|
||||
</div>
|
||||
<!-- Spacer -->
|
||||
<div class="flex-grow"></div>
|
||||
<ProjectCardImportFileArea
|
||||
v-if="!isPendingFileUpload(item)"
|
||||
ref="importArea"
|
||||
:project="project"
|
||||
:model-name="item.fullName"
|
||||
:model="item.model || undefined"
|
||||
class="hidden"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
!isPendingFileUpload(item) &&
|
||||
(pendingVersion || itemType === StructureItemType.EmptyModel)
|
||||
"
|
||||
class="flex items-center h-full"
|
||||
>
|
||||
<ProjectPendingFileImportStatus
|
||||
v-if="pendingVersion"
|
||||
:upload="pendingVersion"
|
||||
type="subversion"
|
||||
class="px-4 w-full"
|
||||
/>
|
||||
<ProjectCardImportFileArea
|
||||
v-else
|
||||
:empty-state-variant="
|
||||
props.gridOrList === GridListToggleValue.Grid ? 'modelGrid' : 'modelList'
|
||||
<template v-if="!isPendingFileUpload(item)">
|
||||
<div
|
||||
v-show="
|
||||
pendingVersion ||
|
||||
itemType === StructureItemType.EmptyModel ||
|
||||
isVersionUploading
|
||||
"
|
||||
:project="project"
|
||||
:model-name="item.fullName"
|
||||
:model="item.model || undefined"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
class="flex items-center h-full"
|
||||
>
|
||||
<ProjectPendingFileImportStatus
|
||||
v-if="pendingVersion"
|
||||
:upload="pendingVersion"
|
||||
type="subversion"
|
||||
class="px-4 w-full h-16"
|
||||
/>
|
||||
<!-- Import area must exist even if hidden, so that we can trigger uploads from actions -->
|
||||
<ProjectCardImportFileArea
|
||||
v-show="!pendingVersion"
|
||||
ref="importArea"
|
||||
empty-state-variant="modelList"
|
||||
:project="project"
|
||||
:model-name="item.fullName"
|
||||
:model="item.model || undefined"
|
||||
class="h-full w-full"
|
||||
@uploading="onVersionUploading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="hasVersions" class="hidden sm:flex items-center gap-x-2">
|
||||
<div class="text-body-3xs text-foreground-2 text-right">
|
||||
Updated
|
||||
@@ -122,7 +118,12 @@
|
||||
</div>
|
||||
<!-- Preview or icon section -->
|
||||
<div
|
||||
v-if="!isPendingFileUpload(item) && item.model?.previewUrl && !pendingVersion"
|
||||
v-if="
|
||||
!isPendingFileUpload(item) &&
|
||||
item.model?.previewUrl &&
|
||||
!pendingVersion &&
|
||||
!isVersionUploading
|
||||
"
|
||||
class="w-20 h-16"
|
||||
>
|
||||
<NuxtLink
|
||||
@@ -236,8 +237,8 @@ import type { Nullable } from '@speckle/shared'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useIsModelExpanded } from '~~/lib/projects/composables/models'
|
||||
import { HorizontalDirection } from '~~/lib/common/composables/window'
|
||||
import { GridListToggleValue } from '~~/lib/layout/helpers/components'
|
||||
import { useCanCreateModel } from '~/lib/projects/composables/permissions'
|
||||
import type { FileAreaUploadingPayload } from '~/lib/form/helpers/fileUpload'
|
||||
|
||||
/**
|
||||
* TODO: The template in this file is a complete mess, needs refactoring
|
||||
@@ -292,7 +293,6 @@ const props = defineProps<{
|
||||
item: SingleLevelModelTreeItemFragment | PendingFileUploadFragment
|
||||
project: ProjectPageModelsStructureItem_ProjectFragment
|
||||
isSearchResult?: boolean
|
||||
gridOrList?: GridListToggleValue
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
@@ -302,6 +302,7 @@ const importArea = ref(
|
||||
triggerPicker: () => void
|
||||
}>
|
||||
)
|
||||
const isVersionUploading = ref(false)
|
||||
|
||||
const mp = useMixpanel()
|
||||
const trackFederateModels = () =>
|
||||
@@ -430,9 +431,14 @@ const onModelUpdated = () => {
|
||||
}
|
||||
|
||||
const triggerVersionUpload = () => {
|
||||
if (isVersionUploading.value) return
|
||||
importArea.value?.triggerPicker()
|
||||
}
|
||||
|
||||
const onVersionUploading = (payload: FileAreaUploadingPayload) => {
|
||||
isVersionUploading.value = payload.isUploading
|
||||
}
|
||||
|
||||
const onVersionsClick = () => {
|
||||
if (model.value) {
|
||||
router.push(modelVersionsRoute(props.project.id, model.value.id))
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
:items="items"
|
||||
:loading="isVeryFirstLoading"
|
||||
empty-message="This model has no uploads"
|
||||
:max-height="300"
|
||||
style="max-height: 300px"
|
||||
>
|
||||
<template #file="{ item }">
|
||||
<div
|
||||
@@ -61,6 +61,7 @@
|
||||
<InfiniteLoading
|
||||
v-if="items?.length"
|
||||
:settings="{ identifier }"
|
||||
hide-when-complete
|
||||
@infinite="onInfiniteLoad"
|
||||
/>
|
||||
</template>
|
||||
@@ -139,7 +140,9 @@ const {
|
||||
}
|
||||
})),
|
||||
options: {
|
||||
enabled: open
|
||||
enabled: open,
|
||||
// reload query when dialog opens
|
||||
fetchPolicy: 'cache-and-network'
|
||||
},
|
||||
resolveKey: (vars) => [vars.projectId, vars.modelId],
|
||||
resolveCurrentResult: (res) => res?.project.model.uploads,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div v-for="project in items" :key="project.id">
|
||||
<div class="flex flex-col space-y-4 relative">
|
||||
<!-- Decrementing z-index to ensure later cards don't overflow over earlier card action menus -->
|
||||
<div
|
||||
v-for="(project, i) in items"
|
||||
:key="project.id"
|
||||
:style="{ 'z-index': items.length - i }"
|
||||
class="relative"
|
||||
>
|
||||
<ProjectsProjectDashboardCard
|
||||
:key="project.id"
|
||||
:project="project"
|
||||
|
||||
@@ -85,32 +85,35 @@
|
||||
</div>
|
||||
</div>
|
||||
<div :class="gridClasses">
|
||||
<ProjectPageModelsCard
|
||||
v-for="pendingModel in pendingModels"
|
||||
:key="pendingModel.id"
|
||||
:model="pendingModel"
|
||||
:project="project"
|
||||
show-versions
|
||||
:project-id="project.id"
|
||||
height="h-48"
|
||||
show-actions
|
||||
/>
|
||||
<ProjectPageModelsCard
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
:model="model"
|
||||
:project="project"
|
||||
show-versions
|
||||
show-actions
|
||||
:project-id="project.id"
|
||||
height="h-48"
|
||||
@click="router.push(modelRoute(project.id, model.id))"
|
||||
/>
|
||||
<template v-if="!isModelUploading">
|
||||
<ProjectPageModelsCard
|
||||
v-for="pendingModel in pendingModels"
|
||||
:key="pendingModel.id"
|
||||
:model="pendingModel"
|
||||
:project="project"
|
||||
show-versions
|
||||
:project-id="project.id"
|
||||
height="h-48"
|
||||
show-actions
|
||||
/>
|
||||
<ProjectPageModelsCard
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
:model="model"
|
||||
:project="project"
|
||||
show-versions
|
||||
show-actions
|
||||
:project-id="project.id"
|
||||
height="h-48"
|
||||
@click="router.push(modelRoute(project.id, model.id))"
|
||||
/>
|
||||
</template>
|
||||
<ProjectCardImportFileArea
|
||||
v-if="hasNoModels"
|
||||
v-if="hasNoModels || isModelUploading"
|
||||
empty-state-variant="modelsSection"
|
||||
:project="project"
|
||||
class="h-28 col-span-4"
|
||||
@uploading="onModelUploading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,6 +132,7 @@ import { useGeneralProjectPageUpdateTracking } from '~~/lib/projects/composables
|
||||
import { ChevronRightIcon } from '@heroicons/vue/20/solid'
|
||||
import { workspaceRoute } from '~/lib/common/helpers/route'
|
||||
import { RoleInfo, type StreamRoles } from '@speckle/shared'
|
||||
import type { FileAreaUploadingPayload } from '~/lib/form/helpers/fileUpload'
|
||||
|
||||
defineEmits<{
|
||||
(e: 'moveProject'): void
|
||||
@@ -143,6 +147,8 @@ const props = defineProps<{
|
||||
const router = useRouter()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
|
||||
const isModelUploading = ref(false)
|
||||
|
||||
const isOwner = computed(() => props.project.role === Roles.Stream.Owner)
|
||||
const projectId = computed(() => props.project.id)
|
||||
const updatedAt = computed(() => {
|
||||
@@ -194,4 +200,8 @@ const gridClasses = computed(() => [
|
||||
props.workspacePage && '2xl:[&>*:nth-child(n+2)]:block',
|
||||
'2xl:[&>*:nth-child(n+3)]:block'
|
||||
])
|
||||
|
||||
const onModelUploading = (payload: FileAreaUploadingPayload) => {
|
||||
isModelUploading.value = payload.isUploading
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -37,52 +37,55 @@ export function useFileImport(params: {
|
||||
* model list view uploads, where list items don't necessarily represent real models)
|
||||
*/
|
||||
modelName?: MaybeRef<MaybeNullOrUndefined<string>>
|
||||
/**
|
||||
* If true, the upload will be prepared and validated, but for it to start you must invoke uploadSelected() manually
|
||||
*/
|
||||
manuallyTriggerUpload?: boolean
|
||||
/**
|
||||
* Optionally handle the file upload completion event.
|
||||
*/
|
||||
fileUploadedCallback?: Optional<(file: UploadFileItem) => void>
|
||||
/**
|
||||
* Optionally handle the file selection event.
|
||||
*/
|
||||
fileSelectedCallback?: Optional<() => void>
|
||||
}) {
|
||||
const { project, model } = params
|
||||
const {
|
||||
project,
|
||||
model,
|
||||
manuallyTriggerUpload,
|
||||
fileUploadedCallback,
|
||||
fileSelectedCallback
|
||||
} = params
|
||||
|
||||
const { maxSizeInBytes } = useServerFileUploadLimit()
|
||||
const authToken = useAuthCookie()
|
||||
const apiOrigin = useApiOrigin()
|
||||
|
||||
const accept = ref('.ifc,.stl,.obj')
|
||||
const upload = ref(null as Nullable<UploadFileItem>)
|
||||
const upload = ref(null as Nullable<UploadFileItem & { modelName: Optional<string> }>)
|
||||
const isUploading = ref(false)
|
||||
|
||||
const modelName = computed(() => unref(params.modelName) || unref(model)?.name)
|
||||
|
||||
let onFileUploadedCb: Optional<(file: UploadFileItem) => void> = undefined
|
||||
const onFileUploaded = (cb: (file: UploadFileItem) => void) => {
|
||||
onFileUploadedCb = cb
|
||||
}
|
||||
const isUploadable = computed(() => {
|
||||
if (!upload.value) return false
|
||||
if (upload.value.error) return false
|
||||
if (isUploading.value) return false
|
||||
if (!authToken.value) return false
|
||||
if (!upload.value.file) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const mp = useMixpanel()
|
||||
const onFilesSelected = async (params: {
|
||||
files: UploadableFileItem[]
|
||||
|
||||
const uploadSelected = async (params?: {
|
||||
/**
|
||||
* Optionally override model name to target for the upload
|
||||
*/
|
||||
modelName?: string
|
||||
}) => {
|
||||
if (isUploading.value || !authToken.value) return
|
||||
|
||||
const file = params.files[0]
|
||||
if (!file) return
|
||||
|
||||
upload.value = {
|
||||
...file,
|
||||
result: undefined,
|
||||
progress: 0
|
||||
}
|
||||
|
||||
if (file.error) {
|
||||
return
|
||||
}
|
||||
|
||||
upload.value = {
|
||||
...file,
|
||||
result: undefined,
|
||||
progress: 0
|
||||
}
|
||||
if (!isUploadable.value || !upload.value || !authToken.value) return
|
||||
const finalModelName = params?.modelName || upload.value.modelName
|
||||
|
||||
isUploading.value = true
|
||||
try {
|
||||
@@ -90,7 +93,7 @@ export function useFileImport(params: {
|
||||
{
|
||||
file: upload.value.file,
|
||||
projectId: unref(project).id,
|
||||
modelName: params.modelName || modelName.value || undefined,
|
||||
modelName: finalModelName,
|
||||
authToken: authToken.value,
|
||||
apiOrigin
|
||||
},
|
||||
@@ -106,11 +109,11 @@ export function useFileImport(params: {
|
||||
mp.track('Upload Action', {
|
||||
type: 'action',
|
||||
name: 'create',
|
||||
source: modelName.value ? 'model card' : 'empty card'
|
||||
source: finalModelName ? 'model card' : 'empty card'
|
||||
// extension
|
||||
})
|
||||
|
||||
onFileUploadedCb?.(upload.value)
|
||||
fileUploadedCallback?.(upload.value)
|
||||
} catch (e) {
|
||||
upload.value.result = {
|
||||
uploadStatus: BlobUploadStatus.Error,
|
||||
@@ -123,12 +126,48 @@ export function useFileImport(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const resetSelected = () => {
|
||||
if (isUploading.value) return
|
||||
upload.value = null
|
||||
}
|
||||
|
||||
const onFilesSelected = async (params: {
|
||||
files: UploadableFileItem[]
|
||||
/**
|
||||
* Optionally override model name to target for the upload
|
||||
*/
|
||||
modelName?: string
|
||||
}) => {
|
||||
if (isUploading.value || !authToken.value) return
|
||||
|
||||
const file = params.files[0]
|
||||
if (!file) return
|
||||
|
||||
upload.value = {
|
||||
...file,
|
||||
result: undefined,
|
||||
progress: 0,
|
||||
modelName: params.modelName || modelName.value || undefined
|
||||
}
|
||||
|
||||
if (file.error) {
|
||||
return
|
||||
}
|
||||
|
||||
fileSelectedCallback?.()
|
||||
if (!manuallyTriggerUpload) {
|
||||
await uploadSelected()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
maxSizeInBytes,
|
||||
onFilesSelected,
|
||||
accept,
|
||||
upload,
|
||||
isUploading,
|
||||
onFileUploaded
|
||||
uploadSelected,
|
||||
resetSelected,
|
||||
isUploadable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function useFileUploadProgressCore(params: {
|
||||
const progressBarStyle = computed((): CSSProperties => {
|
||||
const item = unref(params.item)
|
||||
return {
|
||||
width: `${item ? item.progress : 0}%`
|
||||
width: `${Math.max(item ? item.progress : 0, 1)}%`
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { UploadFileItem } from '@speckle/ui-components'
|
||||
|
||||
export type FileAreaUploadingPayload = { isUploading: boolean; upload: UploadFileItem }
|
||||
@@ -35,13 +35,15 @@ export function usePreviewImageBlob(
|
||||
const basePanoramaUrl = computed(() => unref(previewUrl) + '/all')
|
||||
const isEnabled = computed(() => (import.meta.server ? true : unref(enabled)))
|
||||
const cacheBust = ref(0)
|
||||
const isPanoramaPlaceholder = ref(false)
|
||||
|
||||
const ret = {
|
||||
previewUrl: computed(() => url.value),
|
||||
panoramaPreviewUrl: computed(() => panoramaUrl.value),
|
||||
isLoadingPanorama,
|
||||
shouldLoadPanorama,
|
||||
hasDoneFirstLoad: computed(() => hasDoneFirstLoad.value)
|
||||
hasDoneFirstLoad: computed(() => hasDoneFirstLoad.value),
|
||||
isPanoramaPlaceholder: computed(() => isPanoramaPlaceholder.value)
|
||||
}
|
||||
|
||||
// Preload the image
|
||||
@@ -168,6 +170,9 @@ export function usePreviewImageBlob(
|
||||
img.onload = resolve
|
||||
img.onerror = reject
|
||||
})
|
||||
|
||||
// If width is 700px or less, it's the placeholder not the actual panorama
|
||||
isPanoramaPlaceholder.value = img.naturalWidth <= 700
|
||||
}
|
||||
|
||||
panoramaUrl.value = blobUrl
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</template>
|
||||
<template #complete>
|
||||
<!-- No "No more items" message, instead a small amount of spacing -->
|
||||
<div class="h-8"></div>
|
||||
<div :class="{ 'h-8': !hideWhenComplete }"></div>
|
||||
</template>
|
||||
<template #error="{ retry }">
|
||||
<div class="w-full flex flex-col items-center my-2 space-y-2 mt-4">
|
||||
@@ -54,6 +54,10 @@ defineProps<{
|
||||
* Whether to allow retry and show a retry button when loading fails
|
||||
*/
|
||||
allowRetry?: boolean
|
||||
/**
|
||||
* Hide completely and prevent taking any space when not loading
|
||||
*/
|
||||
hideWhenComplete?: boolean
|
||||
}>()
|
||||
|
||||
const wrapper = ref(null as Nullable<HTMLElement>)
|
||||
|
||||
@@ -20,15 +20,17 @@ export default {
|
||||
}
|
||||
} as Meta
|
||||
|
||||
export const Default: StoryObj = {
|
||||
render: (args) => ({
|
||||
components: { Table, ShieldCheckIcon, ShieldExclamationIcon, TrashIcon },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
const buildRender = (
|
||||
options?: Partial<{
|
||||
wrapTemplate: (core: string) => string
|
||||
style?: string
|
||||
}>
|
||||
): StoryObj['render'] => {
|
||||
const { wrapTemplate = (val: string) => val } = options || {}
|
||||
const template = wrapTemplate(`
|
||||
<Table
|
||||
v-bind="args"
|
||||
style="${options?.style || ''}"
|
||||
>
|
||||
<template #name="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -64,8 +66,19 @@ export const Default: StoryObj = {
|
||||
</select>
|
||||
</template>
|
||||
</Table>
|
||||
`
|
||||
}),
|
||||
`)
|
||||
|
||||
return (args) => ({
|
||||
components: { Table, ShieldCheckIcon, ShieldExclamationIcon, TrashIcon },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template
|
||||
})
|
||||
}
|
||||
|
||||
export const Default: StoryObj = {
|
||||
render: buildRender(),
|
||||
args: {
|
||||
columns: [
|
||||
{ id: 'name', header: 'Name', classes: 'col-span-3 truncate' },
|
||||
@@ -226,10 +239,12 @@ export const NoItems: StoryObj = {
|
||||
}
|
||||
}
|
||||
|
||||
export const WithLimitedHeight: StoryObj = {
|
||||
...Default,
|
||||
export const WithLimitedSpace: StoryObj = {
|
||||
render: buildRender({
|
||||
style: 'width: 400px; height: 200px;'
|
||||
}),
|
||||
args: {
|
||||
...Default.args,
|
||||
maxHeight: 200
|
||||
...Default.args
|
||||
// maxHeight: 200
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +1,81 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<template>
|
||||
<div class="text-foreground">
|
||||
<div
|
||||
class="w-full text-sm overflow-x-auto overflow-y-visible simple-scrollbar border border-outline-3 rounded-lg"
|
||||
>
|
||||
<div :class="headerRowClasses" :style="{ paddingRight: paddingRightStyle }">
|
||||
<div
|
||||
v-for="(column, colIndex) in columns"
|
||||
:key="column.id"
|
||||
:class="getHeaderClasses(column.id, colIndex)"
|
||||
>
|
||||
{{ column.header }}
|
||||
</div>
|
||||
<div :class="tableClasses">
|
||||
<div :class="headerRowClasses" :style="{ paddingRight: paddingRightStyle }">
|
||||
<div
|
||||
v-for="(column, colIndex) in columns"
|
||||
:key="column.id"
|
||||
:class="getHeaderClasses(column.id, colIndex)"
|
||||
>
|
||||
{{ column.header }}
|
||||
</div>
|
||||
<div :class="resultContainerClasses" :style="resultContainerStyle">
|
||||
</div>
|
||||
<div :class="resultContainerClasses">
|
||||
<div
|
||||
v-if="loading || !items"
|
||||
class="flex items-center justify-center py-3"
|
||||
tabindex="0"
|
||||
>
|
||||
<CommonLoadingIcon />
|
||||
</div>
|
||||
<template v-else-if="items?.length">
|
||||
<div
|
||||
v-if="loading || !items"
|
||||
class="flex items-center justify-center py-3"
|
||||
tabindex="0"
|
||||
>
|
||||
<CommonLoadingIcon />
|
||||
</div>
|
||||
<template v-else-if="items?.length">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:style="{ paddingRight: paddingRightStyle }"
|
||||
:class="rowsWrapperClasses"
|
||||
tabindex="0"
|
||||
@click="handleRowClick(item)"
|
||||
@keypress="handleRowClick(item)"
|
||||
>
|
||||
<template v-for="(column, colIndex) in columns" :key="column.id">
|
||||
<div :class="getClasses(column.id, colIndex)" tabindex="0">
|
||||
<slot :name="column.id" :item="item">
|
||||
<div class="text-gray-900 font-medium order-1">Placeholder</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="buttons"
|
||||
class="absolute right-1.5 space-x-1 flex items-center p-0 h-full"
|
||||
>
|
||||
<div v-for="button in buttons" :key="button.label">
|
||||
<FormButton
|
||||
v-tippy="button.tooltip"
|
||||
:icon-left="button.icon"
|
||||
size="sm"
|
||||
color="outline"
|
||||
hide-text
|
||||
:disabled="button.disabled"
|
||||
:class="button.class"
|
||||
:to="isString(button.action) ? button.action : undefined"
|
||||
@click.stop="!isString(button.action) ? button.action(item) : noop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
tabindex="0"
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:style="{ paddingRight: paddingRightStyle }"
|
||||
:class="rowsWrapperClasses"
|
||||
tabindex="0"
|
||||
@click="handleRowClick(item)"
|
||||
@keypress="handleRowClick(item)"
|
||||
>
|
||||
<div :class="getClasses(undefined, 0)" tabindex="0">
|
||||
<slot name="empty">
|
||||
<div class="w-full text-center label-light text-foreground-2 italic">
|
||||
{{ emptyMessage }}
|
||||
</div>
|
||||
</slot>
|
||||
<template v-for="(column, colIndex) in columns" :key="column.id">
|
||||
<div :class="getClasses(column.id, colIndex)" tabindex="0">
|
||||
<slot :name="column.id" :item="item">
|
||||
<div class="text-gray-900 font-medium order-1">Placeholder</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="buttons"
|
||||
class="absolute right-1.5 space-x-1 flex items-center p-0 h-full"
|
||||
>
|
||||
<div v-for="button in buttons" :key="button.label">
|
||||
<FormButton
|
||||
v-tippy="button.tooltip"
|
||||
:icon-left="button.icon"
|
||||
size="sm"
|
||||
color="outline"
|
||||
hide-text
|
||||
:disabled="button.disabled"
|
||||
:class="button.class"
|
||||
:to="isString(button.action) ? button.action : undefined"
|
||||
@click.stop="!isString(button.action) ? button.action(item) : noop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="loader" />
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
tabindex="0"
|
||||
:style="{ paddingRight: paddingRightStyle }"
|
||||
:class="rowsWrapperClasses"
|
||||
>
|
||||
<div :class="getClasses(undefined, 0)" tabindex="0">
|
||||
<slot name="empty">
|
||||
<div class="w-full text-center label-light text-foreground-2 italic">
|
||||
{{ emptyMessage }}
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="loader" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts" generic="T extends {id: string}, C extends string">
|
||||
import { noop, isString } from 'lodash'
|
||||
import { computed, type CSSProperties } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { PropAnyComponent } from '~~/src/helpers/common/components'
|
||||
import { CommonLoadingIcon, FormButton } from '~~/src/lib'
|
||||
import { directive as vTippy } from 'vue-tippy'
|
||||
@@ -109,37 +105,37 @@ const props = withDefaults(
|
||||
rowItemsAlign?: 'center' | 'stretch'
|
||||
emptyMessage?: string
|
||||
loading?: boolean
|
||||
maxHeight?: number
|
||||
}>(),
|
||||
{ rowItemsAlign: 'center', emptyMessage: 'No data found' }
|
||||
)
|
||||
|
||||
const tableClasses = computed(() => {
|
||||
const classParts = [
|
||||
'w-full text-foreground text-sm border border-outline-3 rounded-lg',
|
||||
'overflow-x-auto simple-scrollbar',
|
||||
'h-full flex flex-col'
|
||||
]
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const sharedContainerClasses = computed(() => {
|
||||
const classParts = ['w-full min-w-[750px]']
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const resultContainerClasses = computed(() => {
|
||||
const classParts = ['divide-y divide-outline-3 overflow-visible']
|
||||
const classParts = [
|
||||
'divide-y divide-outline-3 overflow-y-auto overflow-x-hidden simple-scrollbar',
|
||||
sharedContainerClasses.value
|
||||
]
|
||||
|
||||
if (props.overflowCells) {
|
||||
classParts.push('pb-32')
|
||||
}
|
||||
|
||||
if (!props.maxHeight) {
|
||||
classParts.push('h-full overflow-visible')
|
||||
} else {
|
||||
classParts.push('overflow-y-auto simple-scrollbar')
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const resultContainerStyle = computed((): CSSProperties => {
|
||||
const style: CSSProperties = {}
|
||||
|
||||
if (props.maxHeight) {
|
||||
style.maxHeight = `${props.maxHeight}px`
|
||||
}
|
||||
|
||||
return style
|
||||
})
|
||||
|
||||
const buttonCount = computed(() => {
|
||||
return (props.buttons || []).length
|
||||
})
|
||||
@@ -219,10 +215,11 @@ const handleRowClick = (item: T) => {
|
||||
|
||||
const headerRowClasses = computed(() => [
|
||||
'z-10 grid grid-cols-12 items-center',
|
||||
'w-full min-w-[750px] space-x-6',
|
||||
'space-x-6',
|
||||
'px-4 py-3',
|
||||
'bg-foundation-2 rounded-t-lg',
|
||||
'font-medium text-body-2xs text-foreground-2',
|
||||
'border-b border-outline-3'
|
||||
'border-b border-outline-3',
|
||||
sharedContainerClasses.value
|
||||
])
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user