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:
Kristaps Fabians Geikins
2025-06-19 16:23:17 +03:00
committed by GitHub
parent 64c87f787e
commit 5b7f28925c
16 changed files with 379 additions and 246 deletions
@@ -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>