feat: show historical model uploads (#4954)

* ensure modelId is always filled

* BE seems fine

* frontendd

* pagination fix

* table max height

* lint fix

* unset tiny limit

* test fix
This commit is contained in:
Kristaps Fabians Geikins
2025-06-18 10:56:33 +03:00
committed by GitHub
parent 4738b97091
commit 2be1592341
29 changed files with 796 additions and 93 deletions
@@ -30,10 +30,15 @@
@deleted="$emit('model-updated')"
/>
<ProjectModelPageDialogEmbed
v-model:open="embedDialogOpen"
v-model:open="isEmbedDialogOpen"
:project="project"
:model-id="model.id"
/>
<ProjectPageModelsUploadsDialog
v-model:open="isUploadsDialogOpen"
:project-id="project.id"
:model-id="model.id"
/>
</div>
</template>
<script setup lang="ts">
@@ -88,7 +93,8 @@ enum ActionTypes {
ViewVersions = 'view-versions',
UploadVersion = 'upload-version',
CopyId = 'copy-id',
Embed = 'embed'
Embed = 'embed',
ViewUploads = 'view-uploads'
}
const emit = defineEmits<{
@@ -115,7 +121,6 @@ const { statusIsCanceled } = useWorkspacePlan(props.project.workspace?.slug || '
const showActionsMenu = ref(false)
const openDialog = ref(null as Nullable<ActionTypes>)
const embedDialogOpen = ref(false)
const canEdit = computed(() => props.model.permissions.canUpdate)
const canDelete = computed(() => props.model.permissions.canDelete)
@@ -166,6 +171,10 @@ const actionsItems = computed<LayoutMenuItem[][]>(() => [
title: 'View versions',
id: ActionTypes.ViewVersions
},
{
title: 'View uploads',
id: ActionTypes.ViewUploads
},
...(isLoggedIn.value
? [
{
@@ -205,6 +214,14 @@ const isDeleteDialogOpen = computed({
get: () => openDialog.value === ActionTypes.Delete,
set: (isOpen) => (openDialog.value = isOpen ? ActionTypes.Delete : null)
})
const isEmbedDialogOpen = computed({
get: () => openDialog.value === ActionTypes.Embed,
set: (isOpen) => (openDialog.value = isOpen ? ActionTypes.Embed : null)
})
const isUploadsDialogOpen = computed({
get: () => openDialog.value === ActionTypes.ViewUploads,
set: (isOpen) => (openDialog.value = isOpen ? ActionTypes.ViewUploads : null)
})
const onActionChosen = (params: { item: LayoutMenuItem; event: MouseEvent }) => {
const { item } = params
@@ -212,6 +229,8 @@ const onActionChosen = (params: { item: LayoutMenuItem; event: MouseEvent }) =>
switch (item.id) {
case ActionTypes.Rename:
case ActionTypes.Delete:
case ActionTypes.Embed:
case ActionTypes.ViewUploads:
openDialog.value = item.id
break
case ActionTypes.Share:
@@ -227,9 +246,6 @@ const onActionChosen = (params: { item: LayoutMenuItem; event: MouseEvent }) =>
case ActionTypes.CopyId:
copy(props.model.id, { successMessage: 'Copied model ID to clipboard' })
break
case ActionTypes.Embed:
embedDialogOpen.value = true
break
}
}
@@ -0,0 +1,206 @@
<template>
<LayoutDialog v-model:open="open" title="Model upload history" :buttons="buttons">
<LayoutTable
:columns="[
{ id: 'file', header: 'File', classes: 'col-span-5' },
{ id: 'size', header: 'Size', classes: 'col-span-2' },
{ id: 'status', header: 'Status', classes: 'col-span-2' },
{ id: 'date', header: 'Date', classes: 'col-span-2' },
{
id: 'actions',
header: '',
classes: 'col-span-1 flex items-center justify-end'
}
]"
:items="items"
:loading="isVeryFirstLoading"
empty-message="This model has no uploads"
:max-height="300"
>
<template #file="{ item }">
<div
v-tippy="{
content: item.fileName.length > 35 ? item.fileName : undefined,
placement: 'top-start',
delay: 300
}"
class="truncate text-foreground"
>
{{ item.fileName }}
</div>
</template>
<template #size="{ item }">
<span class="text-foreground-2">{{ prettyFileSize(item.fileSize) }}</span>
</template>
<template #status="{ item }">
<CommonBadge
v-tippy="getStatusOptions(item).tooltip"
:color-classes="getStatusOptions(item).colorClasses"
>
{{ getStatusOptions(item).label }}
</CommonBadge>
</template>
<template #date="{ item }">
<span
v-tippy="formattedFullDate(item.convertedLastUpdate || item.uploadDate)"
class="text-foreground-2"
>
{{ formattedRelativeDate(item.convertedLastUpdate || item.uploadDate) }}
</span>
</template>
<template #actions="{ item }">
<FormButton
:icon-left="ArrowDownTrayIcon"
hide-text
size="sm"
color="outline"
@click="onDownload(item)"
/>
</template>
<template #loader>
<InfiniteLoading
v-if="items?.length"
:settings="{ identifier }"
@infinite="onInfiniteLoad"
/>
</template>
</LayoutTable>
</LayoutDialog>
</template>
<script setup lang="ts">
import { ArrowDownTrayIcon } from '@heroicons/vue/24/outline'
import {
FileUploadConvertedStatus,
fileUploadConvertedStatusLabels
} from '@speckle/shared/blobs'
import type { LayoutDialogButton } from '@speckle/ui-components'
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
import { graphql } from '~/lib/common/generated/gql'
import type { ProjectPageModelsUploadsDialog_FileUploadFragment } from '~/lib/common/generated/gql/graphql'
import { useFileDownload } from '~/lib/core/composables/fileUpload'
import { prettyFileSize } from '~~/lib/core/helpers/file'
graphql(`
fragment ProjectPageModelsUploadsDialog_FileUpload on FileUpload {
id
convertedStatus
convertedMessage
fileName
fileSize
convertedLastUpdate
uploadDate
uploadComplete
branchName
}
`)
const getModelUploadsQuery = graphql(`
query GetModelUploads(
$projectId: String!
$modelId: String!
$input: GetModelUploadsInput!
) {
project(id: $projectId) {
id
model(id: $modelId) {
id
uploads(input: $input) {
totalCount
cursor
items {
id
...ProjectPageModelsUploadsDialog_FileUpload
}
}
}
}
}
`)
const props = defineProps<{
projectId: string
modelId: string
}>()
const open = defineModel<boolean>('open', { required: true })
const {
identifier,
onInfiniteLoad,
query: { result },
isVeryFirstLoading
} = usePaginatedQuery({
query: getModelUploadsQuery,
baseVariables: computed(() => ({
projectId: props.projectId,
modelId: props.modelId,
input: {
cursor: null as string | null
}
})),
options: {
enabled: open
},
resolveKey: (vars) => [vars.projectId, vars.modelId],
resolveCurrentResult: (res) => res?.project.model.uploads,
resolveNextPageVariables: (baseVars, cursor) => ({
...baseVars,
input: {
...baseVars.input,
cursor
}
}),
resolveCursorFromVariables: (vars) => vars.input.cursor
})
const { download } = useFileDownload()
const items = computed(() => result.value?.project.model.uploads.items)
const buttons = computed((): LayoutDialogButton[] => [
{
text: 'Close',
onClick: () => {
open.value = false
}
}
])
const getStatusOptions = (item: ProjectPageModelsUploadsDialog_FileUploadFragment) => {
let colorClasses: string | undefined = undefined
switch (item.convertedStatus) {
case FileUploadConvertedStatus.Error:
colorClasses = 'bg-danger text-foundation'
break
case FileUploadConvertedStatus.Converting:
colorClasses = 'bg-primary text-foundation'
break
case FileUploadConvertedStatus.Completed:
colorClasses = 'bg-success text-foundation'
break
case FileUploadConvertedStatus.Queued:
colorClasses = 'bg-info text-foundation'
break
}
return {
label:
fileUploadConvertedStatusLabels[
item.convertedStatus as FileUploadConvertedStatus
],
tooltip:
item.convertedStatus === FileUploadConvertedStatus.Error
? item.convertedMessage
: undefined,
colorClasses
}
}
const onDownload = async (item: ProjectPageModelsUploadsDialog_FileUploadFragment) => {
await download({
blobId: item.id,
fileName: item.fileName,
projectId: props.projectId
})
}
</script>
@@ -136,10 +136,10 @@ export const usePaginatedQuery = <
options,
resolveCurrentResult,
resolveNextPageVariables,
resolveInitialResult,
resolveCursorFromVariables
resolveInitialResult
} = params
const cacheBusterKey = ref(0)
const loadingCompleted = ref(false)
// can't be a computed, because we have to invoke it on the result of the fetchMore call,
// before the result has been merged into the cache and the results become merged with results
@@ -164,6 +164,10 @@ export const usePaginatedQuery = <
resolveCurrentResult(useQueryReturn.result.value)
)
const isVeryFirstLoading = computed(
() => useQueryReturn.loading.value && !currentResult.value?.items.length
)
const getCursorForNextPage = () => {
const currRes = currentResult.value
const initRes = resolveInitialResult?.()
@@ -174,9 +178,14 @@ export const usePaginatedQuery = <
}
const onInfiniteLoad = async (state: InfiniteLoaderState) => {
const loadComplete = () => {
state.complete()
loadingCompleted.value = true
}
const cursor = getCursorForNextPage()
let loadMore = hasMoreToLoad(currentResult.value)
if (!loadMore || !cursor) return state.complete()
if (!loadMore || !cursor) return loadComplete()
try {
const res = await useQueryReturn.fetchMore({
@@ -191,22 +200,24 @@ export const usePaginatedQuery = <
state.loaded()
if (!loadMore) {
state.complete()
loadComplete()
}
}
const bustCache = () => {
cacheBusterKey.value++
loadingCompleted.value = false
}
// If for some reason the query is invoked w/ baseVariables & null cursor, we should bust the cache,
// & reset loader state, cause a refetch was triggered for some reason (maybe a cache eviction)
useQueryReturn.onResult(() => {
const vars = useQueryReturn.variables.value
const cursor = vars ? resolveCursorFromVariables(vars) : undefined
// If after the query runs there is still more to load, but loading is marked as complete (which can happen
// if cache is evicted and initial query reruns) - we should bust the cache,
// & reset loader state, so infinite loader restarts
useQueryReturn.onResult((res) => {
if (res.loading) return
if (!cursor) {
// TODO: Maybe add check to skip this on initial result? Lets see how well this works first
// If more to load & loading completed, bust cache
const moreToLoad = hasMoreToLoad(resolveCurrentResult(res?.data))
if (moreToLoad && loadingCompleted.value) {
bustCache()
}
})
@@ -215,7 +226,8 @@ export const usePaginatedQuery = <
query: useQueryReturn,
identifier: queryKey,
onInfiniteLoad,
bustCache
bustCache,
isVeryFirstLoading
}
}
@@ -92,6 +92,8 @@ type Documents = {
"\n fragment ProjectModelsPageResults_Project on Project {\n ...ProjectPageLatestItemsModels\n }\n": typeof types.ProjectModelsPageResults_ProjectFragmentDoc,
"\n fragment ProjectPageModelsStructureItem_Project on Project {\n id\n ...ProjectPageModelsActions_Project\n ...ProjectCardImportFileArea_Project\n ...UseCanCreateModel_Project\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectPageModelsStructureItem_ProjectFragmentDoc,
"\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n }\n hasChildren\n updatedAt\n }\n": typeof types.SingleLevelModelTreeItemFragmentDoc,
"\n fragment ProjectPageModelsUploadsDialog_FileUpload on FileUpload {\n id\n convertedStatus\n convertedMessage\n fileName\n fileSize\n convertedLastUpdate\n uploadDate\n uploadComplete\n branchName\n }\n": typeof types.ProjectPageModelsUploadsDialog_FileUploadFragmentDoc,
"\n query GetModelUploads(\n $projectId: String!\n $modelId: String!\n $input: GetModelUploadsInput!\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n uploads(input: $input) {\n totalCount\n cursor\n items {\n id\n ...ProjectPageModelsUploadsDialog_FileUpload\n }\n }\n }\n }\n }\n": typeof types.GetModelUploadsDocument,
"\n fragment ProjectPageModelsCardDeleteDialog on Model {\n id\n name\n }\n": typeof types.ProjectPageModelsCardDeleteDialogFragmentDoc,
"\n fragment ProjectPageModelsCardRenameDialog on Model {\n id\n name\n description\n }\n": typeof types.ProjectPageModelsCardRenameDialogFragmentDoc,
"\n query ProjectPageSettingsGeneral($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageSettingsGeneralBlockProjectInfo_Project\n ...ProjectPageSettingsGeneralBlockAccess_Project\n ...ProjectPageSettingsGeneralBlockDiscussions_Project\n ...ProjectPageSettingsGeneralBlockLeave_Project\n ...ProjectPageSettingsGeneralBlockDelete_Project\n ...ProjectPageTeamInternals_Project\n }\n }\n": typeof types.ProjectPageSettingsGeneralDocument,
@@ -540,6 +542,8 @@ const documents: Documents = {
"\n fragment ProjectModelsPageResults_Project on Project {\n ...ProjectPageLatestItemsModels\n }\n": types.ProjectModelsPageResults_ProjectFragmentDoc,
"\n fragment ProjectPageModelsStructureItem_Project on Project {\n id\n ...ProjectPageModelsActions_Project\n ...ProjectCardImportFileArea_Project\n ...UseCanCreateModel_Project\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectPageModelsStructureItem_ProjectFragmentDoc,
"\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n }\n hasChildren\n updatedAt\n }\n": types.SingleLevelModelTreeItemFragmentDoc,
"\n fragment ProjectPageModelsUploadsDialog_FileUpload on FileUpload {\n id\n convertedStatus\n convertedMessage\n fileName\n fileSize\n convertedLastUpdate\n uploadDate\n uploadComplete\n branchName\n }\n": types.ProjectPageModelsUploadsDialog_FileUploadFragmentDoc,
"\n query GetModelUploads(\n $projectId: String!\n $modelId: String!\n $input: GetModelUploadsInput!\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n uploads(input: $input) {\n totalCount\n cursor\n items {\n id\n ...ProjectPageModelsUploadsDialog_FileUpload\n }\n }\n }\n }\n }\n": types.GetModelUploadsDocument,
"\n fragment ProjectPageModelsCardDeleteDialog on Model {\n id\n name\n }\n": types.ProjectPageModelsCardDeleteDialogFragmentDoc,
"\n fragment ProjectPageModelsCardRenameDialog on Model {\n id\n name\n description\n }\n": types.ProjectPageModelsCardRenameDialogFragmentDoc,
"\n query ProjectPageSettingsGeneral($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageSettingsGeneralBlockProjectInfo_Project\n ...ProjectPageSettingsGeneralBlockAccess_Project\n ...ProjectPageSettingsGeneralBlockDiscussions_Project\n ...ProjectPageSettingsGeneralBlockLeave_Project\n ...ProjectPageSettingsGeneralBlockDelete_Project\n ...ProjectPageTeamInternals_Project\n }\n }\n": types.ProjectPageSettingsGeneralDocument,
@@ -1236,6 +1240,14 @@ export function graphql(source: "\n fragment ProjectPageModelsStructureItem_Pro
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n }\n hasChildren\n updatedAt\n }\n"): (typeof documents)["\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n }\n hasChildren\n updatedAt\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ProjectPageModelsUploadsDialog_FileUpload on FileUpload {\n id\n convertedStatus\n convertedMessage\n fileName\n fileSize\n convertedLastUpdate\n uploadDate\n uploadComplete\n branchName\n }\n"): (typeof documents)["\n fragment ProjectPageModelsUploadsDialog_FileUpload on FileUpload {\n id\n convertedStatus\n convertedMessage\n fileName\n fileSize\n convertedLastUpdate\n uploadDate\n uploadComplete\n branchName\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query GetModelUploads(\n $projectId: String!\n $modelId: String!\n $input: GetModelUploadsInput!\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n uploads(input: $input) {\n totalCount\n cursor\n items {\n id\n ...ProjectPageModelsUploadsDialog_FileUpload\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query GetModelUploads(\n $projectId: String!\n $modelId: String!\n $input: GetModelUploadsInput!\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n uploads(input: $input) {\n totalCount\n cursor\n items {\n id\n ...ProjectPageModelsUploadsDialog_FileUpload\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -230,6 +230,10 @@ function createCache(): InMemoryCache {
},
permissions: {
merge: mergeAsObjectsFunction
},
uploads: {
keyArgs: ['input', ['limit']],
merge: buildAbstractCollectionMergeFunction('FileUploadCollection')
}
}
},
+2 -2
View File
@@ -24,7 +24,7 @@
"datadog:publish-sourcemaps:dev": "DATADOG_SITE=\"datadoghq.eu\" datadog-ci sourcemaps upload ./.output/public/_nuxt --service=\"fe2-dev/test\" --release-version=\"unknown\" --minified-path-prefix=/_nuxt"
},
"dependencies": {
"@apollo/client": "^3.12.4",
"@apollo/client": "^3.13.8",
"@artmizu/nuxt-prometheus": "^2.2.1",
"@datadog/browser-rum": "^5.11.0",
"@headlessui/vue": "^1.7.13",
@@ -55,7 +55,7 @@
"@tiptap/suggestion": "2.10.3",
"@tiptap/vue-3": "2.10.3",
"@tryghost/content-api": "^1.11.21",
"@vue/apollo-composable": "npm:@speckle/apollo-composable@4.2.1-patch.1",
"@vue/apollo-composable": "^4.2.2",
"@vue/apollo-ssr": "4.0.0",
"@vueuse/core": "^10.9.0",
"apollo-upload-client": "^18.0.1",
@@ -23,11 +23,33 @@ extend type Project {
pendingImportedModels(limit: Int = 25): [FileUpload!]!
}
type FileUploadCollection {
totalCount: Int!
cursor: String
items: [FileUpload!]!
}
input GetModelUploadsInput {
"""
The maximum number of uploads to return.
"""
limit: Int = 25
"""
The cursor for pagination.
"""
cursor: String
}
extend type Model {
"""
Returns a list of versions that are being created from a file import
"""
pendingImportedVersions(limit: Int = 25): [FileUpload!]!
"""
Get all file uploads ever done in this model
"""
uploads(input: GetModelUploadsInput): FileUploadCollection!
}
type FileUpload {
@@ -1052,6 +1052,13 @@ export type FileUpload = {
userId: Scalars['String']['output'];
};
export type FileUploadCollection = {
__typename?: 'FileUploadCollection';
cursor?: Maybe<Scalars['String']['output']>;
items: Array<FileUpload>;
totalCount: Scalars['Int']['output'];
};
export type FileUploadMutations = {
__typename?: 'FileUploadMutations';
/**
@@ -1124,6 +1131,13 @@ export type GenerateFileUploadUrlOutput = {
url: Scalars['String']['output'];
};
export type GetModelUploadsInput = {
/** The cursor for pagination. */
cursor?: InputMaybe<Scalars['String']['input']>;
/** The maximum number of uploads to return. */
limit?: InputMaybe<Scalars['Int']['input']>;
};
export type InvitableCollaboratorsFilter = {
search?: InputMaybe<Scalars['String']['input']>;
};
@@ -1342,6 +1356,8 @@ export type Model = {
permissions: ModelPermissionChecks;
previewUrl?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
/** Get all file uploads ever done in this model */
uploads: FileUploadCollection;
version: Version;
versions: VersionCollection;
};
@@ -1358,6 +1374,11 @@ export type ModelPendingImportedVersionsArgs = {
};
export type ModelUploadsArgs = {
input?: InputMaybe<GetModelUploadsInput>;
};
export type ModelVersionArgs = {
id: Scalars['String']['input'];
};
@@ -5417,6 +5438,7 @@ export type ResolversTypes = {
EditCommentInput: EditCommentInput;
EmailVerificationRequestInput: EmailVerificationRequestInput;
FileUpload: ResolverTypeWrapper<FileUploadGraphQLReturn>;
FileUploadCollection: ResolverTypeWrapper<Omit<FileUploadCollection, 'items'> & { items: Array<ResolversTypes['FileUpload']> }>;
FileUploadMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
Float: ResolverTypeWrapper<Scalars['Float']['output']>;
GendoAIRender: ResolverTypeWrapper<GendoAIRenderGraphQLReturn>;
@@ -5424,6 +5446,7 @@ export type ResolversTypes = {
GendoAIRenderInput: GendoAiRenderInput;
GenerateFileUploadUrlInput: GenerateFileUploadUrlInput;
GenerateFileUploadUrlOutput: ResolverTypeWrapper<GenerateFileUploadUrlOutput>;
GetModelUploadsInput: GetModelUploadsInput;
ID: ResolverTypeWrapper<Scalars['ID']['output']>;
Int: ResolverTypeWrapper<Scalars['Int']['output']>;
InvitableCollaboratorsFilter: InvitableCollaboratorsFilter;
@@ -5757,6 +5780,7 @@ export type ResolversParentTypes = {
EditCommentInput: EditCommentInput;
EmailVerificationRequestInput: EmailVerificationRequestInput;
FileUpload: FileUploadGraphQLReturn;
FileUploadCollection: Omit<FileUploadCollection, 'items'> & { items: Array<ResolversParentTypes['FileUpload']> };
FileUploadMutations: MutationsObjectGraphQLReturn;
Float: Scalars['Float']['output'];
GendoAIRender: GendoAIRenderGraphQLReturn;
@@ -5764,6 +5788,7 @@ export type ResolversParentTypes = {
GendoAIRenderInput: GendoAiRenderInput;
GenerateFileUploadUrlInput: GenerateFileUploadUrlInput;
GenerateFileUploadUrlOutput: GenerateFileUploadUrlOutput;
GetModelUploadsInput: GetModelUploadsInput;
ID: Scalars['ID']['output'];
Int: Scalars['Int']['output'];
InvitableCollaboratorsFilter: InvitableCollaboratorsFilter;
@@ -6457,6 +6482,13 @@ export type FileUploadResolvers<ContextType = GraphQLContext, ParentType extends
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FileUploadCollectionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['FileUploadCollection'] = ResolversParentTypes['FileUploadCollection']> = {
cursor?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
items?: Resolver<Array<ResolversTypes['FileUpload']>, ParentType, ContextType>;
totalCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FileUploadMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['FileUploadMutations'] = ResolversParentTypes['FileUploadMutations']> = {
generateUploadUrl?: Resolver<ResolversTypes['GenerateFileUploadUrlOutput'], ParentType, ContextType, RequireFields<FileUploadMutationsGenerateUploadUrlArgs, 'input'>>;
startFileImport?: Resolver<ResolversTypes['FileUpload'], ParentType, ContextType, RequireFields<FileUploadMutationsStartFileImportArgs, 'input'>>;
@@ -6577,6 +6609,7 @@ export type ModelResolvers<ContextType = GraphQLContext, ParentType extends Reso
permissions?: Resolver<ResolversTypes['ModelPermissionChecks'], ParentType, ContextType>;
previewUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
uploads?: Resolver<ResolversTypes['FileUploadCollection'], ParentType, ContextType, Partial<ModelUploadsArgs>>;
version?: Resolver<ResolversTypes['Version'], ParentType, ContextType, RequireFields<ModelVersionArgs, 'id'>>;
versions?: Resolver<ResolversTypes['VersionCollection'], ParentType, ContextType, RequireFields<ModelVersionsArgs, 'limit'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@@ -7871,6 +7904,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
CurrencyBasedPrices?: CurrencyBasedPricesResolvers<ContextType>;
DateTime?: GraphQLScalarType;
FileUpload?: FileUploadResolvers<ContextType>;
FileUploadCollection?: FileUploadCollectionResolvers<ContextType>;
FileUploadMutations?: FileUploadMutationsResolvers<ContextType>;
GendoAIRender?: GendoAiRenderResolvers<ContextType>;
GendoAIRenderCollection?: GendoAiRenderCollectionResolvers<ContextType>;
@@ -1032,6 +1032,13 @@ export type FileUpload = {
userId: Scalars['String']['output'];
};
export type FileUploadCollection = {
__typename?: 'FileUploadCollection';
cursor?: Maybe<Scalars['String']['output']>;
items: Array<FileUpload>;
totalCount: Scalars['Int']['output'];
};
export type FileUploadMutations = {
__typename?: 'FileUploadMutations';
/**
@@ -1104,6 +1111,13 @@ export type GenerateFileUploadUrlOutput = {
url: Scalars['String']['output'];
};
export type GetModelUploadsInput = {
/** The cursor for pagination. */
cursor?: InputMaybe<Scalars['String']['input']>;
/** The maximum number of uploads to return. */
limit?: InputMaybe<Scalars['Int']['input']>;
};
export type InvitableCollaboratorsFilter = {
search?: InputMaybe<Scalars['String']['input']>;
};
@@ -1322,6 +1336,8 @@ export type Model = {
permissions: ModelPermissionChecks;
previewUrl?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
/** Get all file uploads ever done in this model */
uploads: FileUploadCollection;
version: Version;
versions: VersionCollection;
};
@@ -1338,6 +1354,11 @@ export type ModelPendingImportedVersionsArgs = {
};
export type ModelUploadsArgs = {
input?: InputMaybe<GetModelUploadsInput>;
};
export type ModelVersionArgs = {
id: Scalars['String']['input'];
};
@@ -18,7 +18,13 @@ export type GetFileInfoV2 = (args: {
export type SaveUploadFileInput = Pick<
FileUploadRecord,
'streamId' | 'branchName' | 'userId' | 'fileName' | 'fileType' | 'fileSize'
| 'streamId'
| 'branchName'
| 'userId'
| 'fileName'
| 'fileType'
| 'fileSize'
| 'modelId'
> & { fileId: string }
export type SaveUploadFileInputV2 = Pick<
@@ -40,6 +46,11 @@ export type SaveUploadFileV2 = (
args: SaveUploadFileInputV2
) => Promise<FileUploadRecordV2>
export type UpdateFileUpload = (args: {
id: string
upload: Partial<FileUploadRecord>
}) => Promise<FileUploadRecord>
export type GarbageCollectPendingUploadedFiles = (args: {
timeoutThresholdSeconds: number
}) => Promise<FileUploadRecord[]>
@@ -79,3 +90,28 @@ export type RegisterUploadCompleteAndStartFileImport = (args: {
expectedETag: string
maximumFileSize: number
}) => Promise<FileUploadRecordV2 & { modelName: string }>
export type GetModelUploadsBaseArgs = {
projectId: string
modelId: string
}
export type GetModelUploadsArgs = GetModelUploadsBaseArgs & {
limit?: number
cursor?: string | null
}
export type GetModelUploadsItems = (params: GetModelUploadsArgs) => Promise<{
items: FileUploadRecord[]
cursor: string | null
}>
export type GetModelUploadsTotalCount = (
params: GetModelUploadsBaseArgs
) => Promise<number>
export type GetModelUploads = (params: GetModelUploadsArgs) => Promise<{
items: FileUploadRecord[]
totalCount: number
cursor: string | null
}>
@@ -4,6 +4,8 @@ import {
getBranchPendingVersionsFactory,
getFileInfoFactory,
getFileInfoFactoryV2,
getModelUploadsItemsFactory,
getModelUploadsTotalCountFactory,
getStreamFileUploadsFactory,
getStreamPendingModelsFactory,
saveUploadFileFactory,
@@ -232,6 +234,7 @@ const fileUploadMutations: Resolvers['FileUploadMutations'] = {
}
}
}
import { getModelUploadsFactory } from '@/modules/fileuploads/services/management'
export = {
Stream: {
@@ -260,6 +263,20 @@ export = {
parent.name,
args
)
},
async uploads(parent, args) {
const projectDb = await getProjectDbClient({ projectId: parent.streamId })
const getModelUploads = getModelUploadsFactory({
getModelUploadsItems: getModelUploadsItemsFactory({ db: projectDb }),
getModelUploadsTotalCount: getModelUploadsTotalCountFactory({ db: projectDb })
})
return await getModelUploads({
modelId: parent.id,
projectId: parent.streamId,
limit: args.input?.limit ?? 25,
cursor: args.input?.cursor
})
}
},
FileUpload: {
@@ -267,11 +284,19 @@ export = {
modelName: (parent) => parent.branchName,
convertedVersionId: (parent) => parent.convertedCommitId,
async model(parent, _args, ctx) {
const projectDb = await getProjectDbClient({ projectId: parent.streamId })
const { streamId, modelId, branchName } = parent
const projectDb = await getProjectDbClient({ projectId: streamId })
if (modelId) {
return await ctx.loaders
.forRegion({ db: projectDb })
.branches.getById.load(modelId)
}
return await ctx.loaders
.forRegion({ db: projectDb })
.streams.getStreamBranchByName.forStream(parent.streamId)
.load(parent.branchName.toLowerCase())
.streams.getStreamBranchByName.forStream(streamId)
.load(branchName.toLowerCase())
}
},
Mutation: {
+3 -1
View File
@@ -18,7 +18,8 @@ import { listenFor } from '@/modules/core/utils/dbNotificationListener'
import { getEventBus } from '@/modules/shared/services/eventBus'
import {
expireOldPendingUploadsFactory,
getFileInfoFactory
getFileInfoFactory,
updateFileUploadFactory
} from '@/modules/fileuploads/repositories/fileUploads'
import { db } from '@/db/knex'
import { getFileImportTimeLimitMinutes } from '@/modules/shared/helpers/envHelper'
@@ -137,6 +138,7 @@ export const init: SpeckleModule['init'] = async ({ app, isInitial }) => {
getFileInfo: getFileInfoFactory({ db: projectDb }),
publish,
getStreamBranchByName: getStreamBranchByNameFactory({ db: projectDb }),
updateFileUpload: updateFileUploadFactory({ db: projectDb }),
eventEmit: getEventBus().emit
})(parsedMessage)
})
@@ -0,0 +1,16 @@
import { Knex } from 'knex'
const TABLE_NAME = 'file_uploads'
const METADATA_FIELD = 'metadata'
export async function up(knex: Knex): Promise<void> {
await knex.schema.table(TABLE_NAME, (table) => {
table.dropColumn(METADATA_FIELD)
})
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.table(TABLE_NAME, (table) => {
table.json(METADATA_FIELD).nullable().defaultTo(null)
})
}
@@ -1,13 +1,16 @@
import { Branches, FileUploads, knex } from '@/modules/core/dbSchema'
import {
UpdateFileStatus,
GarbageCollectPendingUploadedFiles,
GetFileInfo,
SaveUploadFile,
SaveUploadFileV2,
SaveUploadFileInput,
SaveUploadFileInputV2,
GetFileInfoV2
GetFileInfoV2,
UpdateFileUpload,
GetModelUploadsItems,
GetModelUploadsBaseArgs,
GetModelUploadsTotalCount
} from '@/modules/fileuploads/domain/operations'
import {
FileUploadConvertedStatus,
@@ -16,11 +19,19 @@ import {
} from '@/modules/fileuploads/helpers/types'
import { Knex } from 'knex'
import { FileImportJobNotFoundError } from '@/modules/fileuploads/helpers/errors'
import { compositeCursorTools } from '@/modules/shared/helpers/graphqlHelper'
import { clamp } from 'lodash'
const tables = {
fileUploads: (db: Knex) => db<FileUploadRecord>(FileUploads.name)
}
const getCursorTools = () =>
compositeCursorTools({
schema: FileUploads,
cols: ['convertedLastUpdate', 'id']
})
export const getFileInfoFactory =
(deps: { db: Knex }): GetFileInfo =>
async (params) => {
@@ -100,7 +111,8 @@ export const saveUploadFileFactory =
userId,
fileName,
fileType,
fileSize
fileSize,
modelId
}: SaveUploadFileInput) => {
const dbFile: Partial<FileUploadRecord> = {
id: fileId,
@@ -110,7 +122,8 @@ export const saveUploadFileFactory =
fileName,
fileType,
fileSize,
uploadComplete: true
uploadComplete: true,
modelId
}
const [newRecord] = await tables.fileUploads(deps.db).insert(dbFile, '*')
return newRecord as FileUploadRecord
@@ -232,23 +245,61 @@ export const getBranchPendingVersionsFactory =
return await q
}
export const updateFileStatusFactory =
(deps: { db: Knex }): UpdateFileStatus =>
export const updateFileUploadFactory =
(deps: { db: Knex }): UpdateFileUpload =>
async (params) => {
const { fileId, status, convertedMessage, convertedCommitId } = params
const fileInfos = await tables
const { id, upload } = params
const updatedFile = await tables
.fileUploads(deps.db)
.update<FileUploadRecord[]>({
[FileUploads.withoutTablePrefix.col.convertedStatus]: status,
[FileUploads.withoutTablePrefix.col.convertedLastUpdate]: knex.fn.now(),
[FileUploads.withoutTablePrefix.col.convertedMessage]: convertedMessage,
[FileUploads.withoutTablePrefix.col.convertedCommitId]: convertedCommitId
})
.where({ [FileUploads.withoutTablePrefix.col.id]: fileId })
.update(upload)
.where({ [FileUploads.col.id]: id })
.returning<FileUploadRecord[]>('*')
if (fileInfos.length === 0) {
throw new FileImportJobNotFoundError(`File with id ${fileId} not found`)
if (updatedFile.length === 0) {
throw new FileImportJobNotFoundError(`File with id ${id} not found`)
}
return fileInfos[0]
return updatedFile[0]
}
const getModelUploadsBaseQueryFactory =
(deps: { db: Knex }) => (params: GetModelUploadsBaseArgs) => {
const { projectId, modelId } = params
const q = tables
.fileUploads(deps.db)
.where(FileUploads.col.streamId, projectId)
.andWhere(FileUploads.col.modelId, modelId)
return q
}
export const getModelUploadsItemsFactory =
(deps: { db: Knex }): GetModelUploadsItems =>
async (params) => {
const limit = clamp(params.limit || 0, 0, 100)
const { filterByCursor, resolveNewCursor } = getCursorTools()
const q = getModelUploadsBaseQueryFactory(deps)(params)
.orderBy(FileUploads.col.convertedLastUpdate, 'desc')
.limit(limit)
filterByCursor({
query: q,
cursor: params.cursor
})
const rows = await q
const newCursor = resolveNewCursor(rows)
return {
items: rows,
cursor: newCursor
}
}
export const getModelUploadsTotalCountFactory =
(deps: { db: Knex }): GetModelUploadsTotalCount =>
async (params) => {
const q = getModelUploadsBaseQueryFactory(deps)(params)
const [{ count }] = await q.count()
return parseInt(count + '')
}
@@ -9,7 +9,7 @@ import { fileImportResultPayload } from '@speckle/shared/workers/fileimport'
import { onFileImportResultFactory } from '@/modules/fileuploads/services/resultHandler'
import {
saveUploadFileFactoryV2,
updateFileStatusFactory
updateFileUploadFactory
} from '@/modules/fileuploads/repositories/fileUploads'
import { validateRequest } from 'zod-express'
import { z } from 'zod'
@@ -170,7 +170,7 @@ export const nextGenFileImporterRouterFactory = (): Router => {
const onFileImportResult = onFileImportResultFactory({
logger: logger.child({ fileUploadStatus: jobResult.status }),
updateFileStatus: updateFileStatusFactory({ db: projectDb }),
updateFileUpload: updateFileUploadFactory({ db: projectDb }),
publish
})
@@ -43,8 +43,11 @@ export const fileuploadRouterFactory = (): Router => {
})
const projectDb = await getProjectDbClient({ projectId })
const getStreamBranchByName = getStreamBranchByNameFactory({ db: projectDb })
const branch = await getStreamBranchByName(projectId, branchName)
const insertNewUploadAndNotify = insertNewUploadAndNotifyFactory({
getStreamBranchByName: getStreamBranchByNameFactory({ db: projectDb }),
getStreamBranchByName,
saveUploadFile: saveUploadFileFactory({ db: projectDb }),
publish,
emit: getEventBus().emit
@@ -63,11 +66,12 @@ export const fileuploadRouterFactory = (): Router => {
await insertNewUploadAndNotify({
fileId: upload.blobId,
streamId: projectId,
branchName,
branchName: branch?.name || branchName,
userId,
fileName: upload.fileName,
fileType: upload.fileName?.split('.').pop() || '', //FIXME
fileSize: upload.fileSize
fileSize: upload.fileSize,
modelId: branch?.id || null
})
})
)
@@ -9,6 +9,9 @@ import {
NotifyChangeInFileStatus,
SaveUploadFileV2,
PushJobToFileImporter,
GetModelUploads,
GetModelUploadsItems,
GetModelUploadsTotalCount,
InsertNewUploadAndNotifyV2,
InsertNewUploadAndNotify
} from '@/modules/fileuploads/domain/operations'
@@ -159,3 +162,23 @@ export const notifyChangeInFileStatus =
projectId: streamId
})
}
export const getModelUploadsFactory =
(deps: {
getModelUploadsItems: GetModelUploadsItems
getModelUploadsTotalCount: GetModelUploadsTotalCount
}): GetModelUploads =>
async (params) => {
const [{ items, cursor }, totalCount] = await Promise.all([
params.limit === 0
? { items: [], cursor: null }
: deps.getModelUploadsItems(params),
deps.getModelUploadsTotalCount(params)
])
return {
items,
totalCount,
cursor
}
}
@@ -1,7 +1,7 @@
import { Logger } from '@/observability/logging'
import {
ProcessFileImportResult,
UpdateFileStatus
UpdateFileUpload
} from '@/modules/fileuploads/domain/operations'
import {
FileImportSubscriptions,
@@ -16,9 +16,10 @@ import {
jobResultToConvertedMessage
} from '@/modules/fileuploads/helpers/convert'
import { ensureError } from '@speckle/shared'
import { FileUploadRecord } from '@/modules/fileuploads/helpers/types'
type OnFileImportResultDeps = {
updateFileStatus: UpdateFileStatus
updateFileUpload: UpdateFileUpload
publish: PublishSubscription
logger: Logger
}
@@ -42,13 +43,16 @@ export const onFileImportResultFactory =
convertedCommitId = jobResult.result.versionId
}
let updatedFile
let updatedFile: FileUploadRecord
try {
updatedFile = await deps.updateFileStatus({
fileId: jobId,
status,
convertedMessage,
convertedCommitId
updatedFile = await deps.updateFileUpload({
id: jobId,
upload: {
convertedStatus: status,
convertedLastUpdate: new Date(),
convertedMessage,
convertedCommitId
}
})
} catch (e) {
const err = ensureError(e)
@@ -8,7 +8,7 @@ import {
ProjectPendingModelsUpdatedMessageType,
ProjectPendingVersionsUpdatedMessageType
} from '@/modules/core/graph/generated/graphql'
import { GetFileInfo } from '@/modules/fileuploads/domain/operations'
import { GetFileInfo, UpdateFileUpload } from '@/modules/fileuploads/domain/operations'
import { GetStreamBranchByName } from '@/modules/core/domain/branches/operations'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
import { ModelEvents } from '@/modules/core/domain/branches/events'
@@ -19,6 +19,7 @@ import { FileUploadInternalError } from '@/modules/fileuploads/helpers/errors'
type OnFileImportProcessedDeps = {
getFileInfo: GetFileInfo
getStreamBranchByName: GetStreamBranchByName
updateFileUpload: UpdateFileUpload
publish: PublishSubscription
eventEmit: EventBusEmit
}
@@ -45,11 +46,21 @@ export const onFileImportProcessedFactory =
const [upload, branch] = await Promise.all([
deps.getFileInfo({ fileId: uploadId }),
isNewBranch ? deps.getStreamBranchByName(streamId, branchName) : null
deps.getStreamBranchByName(streamId, branchName)
])
if (!upload) return
if (upload.streamId !== streamId) return
// Update upload to reference the actual model/branch created
if (branch) {
await deps.updateFileUpload({
id: upload.id,
upload: {
modelId: branch.id
}
})
}
if (upload.convertedStatus === FileUploadConvertedStatus.Error) {
//TODO in future differentiate between internal server errors and user errors
const err = new FileUploadInternalError(
@@ -16,7 +16,8 @@ export const createFileUploadJob = (params: { projectId: string; userId: string
userId,
fileName: cryptoRandomString({ length: 10 }),
fileType: cryptoRandomString({ length: 3 }),
fileSize: randomInt(1, 1e6)
fileSize: randomInt(1, 1e6),
modelId: null
}
return saveUploadFile(data)
@@ -69,7 +69,8 @@ describe('FileUploads @fileuploads', () => {
fileId,
fileName: 'testfile.txt',
fileSize: 100,
fileType: 'text/plain'
fileType: 'text/plain',
modelId: null
})
await sleep(2000)
await garbageCollector({ logger, timeoutThresholdSeconds: 1 })
@@ -98,7 +99,8 @@ describe('FileUploads @fileuploads', () => {
fileId,
fileName: 'testfile.txt',
fileSize: 100,
fileType: 'text/plain'
fileType: 'text/plain',
modelId: null
})
// timeout far in the future, so it won't be garbage collected
await garbageCollector({ logger, timeoutThresholdSeconds: 1 * TIME.hour })
@@ -133,7 +135,8 @@ describe('FileUploads @fileuploads', () => {
fileId,
fileName: 'testfile.txt',
fileSize: 100,
fileType: 'text/plain'
fileType: 'text/plain',
modelId: null
})
const results = await getFileInfoFactory({ db })({
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { AuthContext } from '@/modules/shared/authz'
import { base64Decode, base64Encode } from '@/modules/shared/helpers/cryptoHelper'
import DataLoader from 'dataloader'
@@ -13,6 +14,8 @@ import {
} from '@/modules/shared/errors'
import { MaybeNullOrUndefined, Nullable, Optional } from '@speckle/shared'
import { Knex } from 'knex'
import { SchemaConfig } from '@/modules/core/dbSchema'
import { has, isObjectLike, isString, mapValues, pick, times } from 'lodash'
/**
* Encode cursor to turn it into an opaque & obfuscated value
@@ -73,12 +76,101 @@ export const decodeCompositeCursor = <C extends object>(
return null
}
/**
* Simplifies working with composite cursors in SQL queries. Composite cursors are better because they
* allow duplicate values (e.g. updatedAt date) in different rows
*/
export const compositeCursorTools = <
Config extends SchemaConfig<any, any, any>,
SelectedCols extends Array<keyof Config['col']>
>(args: {
schema: Config
/**
* Order of columns matters - put the primary ordering column first (e.g. updatedAt), then the secondary
* ones like the ID.
*/
cols: SelectedCols
}) => {
type Cursor = {
[Col in SelectedCols[number]]: string
}
type CursorRecord = {
[Col in SelectedCols[number]]: string | Date | number | boolean
}
const encode = (val: Cursor) => encodeCompositeCursor(val)
const decode = (cursor: MaybeNullOrUndefined<string>): Nullable<Cursor> =>
decodeCompositeCursor(
cursor,
(c) => isObjectLike(c) && args.cols.every((col) => has(c, col))
)
/**
* Invoke this on the knex querybuilder to filter the query by the cursor
*/
const filterByCursor = <Query extends Knex.QueryBuilder>(params: {
query: Query
/**
* If falsy, filter will be skipped
*/
cursor: MaybeNullOrUndefined<Cursor | string>
/**
* How the results are sorted. Descending by default.
*/
sort?: 'desc' | 'asc'
}) => {
const { query, sort = 'desc' } = params
const cursor = isString(params.cursor) ? decode(params.cursor) : params.cursor
if (!cursor) return query
const colCount = args.cols.length
const sql = `(${times(colCount, () => '??').join(', ')}) ${
sort === 'desc' ? '<' : '>'
} (${times(colCount, () => '?').join(', ')})` // string like (??, ??) < (?, ?)
// e.g. WHERE (table.updatedAt, table.id) < ('2023-10-01T00:00:00.000Z', '12345')
query.andWhereRaw(sql, [
...args.cols.map((col) => args.schema.col[col]),
...args.cols.map((col) => cursor[col].toString())
])
return query
}
/**
* Feed in an entire page of items and this will build the next cursor accordingly
*/
const resolveNewCursor = (items: Array<CursorRecord>) => {
if (!items.length) return null
const lastItem = items.at(-1)
if (!lastItem) return null
const cursor: Cursor = mapValues(pick(lastItem, args.cols), (value) => {
if (value instanceof Date) {
return value.toISOString()
}
return `${value}`
})
return encode(cursor)
}
return {
encode,
decode,
filterByCursor,
resolveNewCursor
}
}
/**
* All dataloaders must at the very least follow this type
*/
export type ModularizedDataLoadersConstraint = {
[group: string]: Optional<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[loader: string]: DataLoader<any, any> | { clearAll: () => unknown }
}>
}
@@ -1033,6 +1033,13 @@ export type FileUpload = {
userId: Scalars['String']['output'];
};
export type FileUploadCollection = {
__typename?: 'FileUploadCollection';
cursor?: Maybe<Scalars['String']['output']>;
items: Array<FileUpload>;
totalCount: Scalars['Int']['output'];
};
export type FileUploadMutations = {
__typename?: 'FileUploadMutations';
/**
@@ -1105,6 +1112,13 @@ export type GenerateFileUploadUrlOutput = {
url: Scalars['String']['output'];
};
export type GetModelUploadsInput = {
/** The cursor for pagination. */
cursor?: InputMaybe<Scalars['String']['input']>;
/** The maximum number of uploads to return. */
limit?: InputMaybe<Scalars['Int']['input']>;
};
export type InvitableCollaboratorsFilter = {
search?: InputMaybe<Scalars['String']['input']>;
};
@@ -1323,6 +1337,8 @@ export type Model = {
permissions: ModelPermissionChecks;
previewUrl?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
/** Get all file uploads ever done in this model */
uploads: FileUploadCollection;
version: Version;
versions: VersionCollection;
};
@@ -1339,6 +1355,11 @@ export type ModelPendingImportedVersionsArgs = {
};
export type ModelUploadsArgs = {
input?: InputMaybe<GetModelUploadsInput>;
};
export type ModelVersionArgs = {
id: Scalars['String']['input'];
};
+19
View File
@@ -38,3 +38,22 @@ export const BlobUploadStatus = <const>{
}
export type BlobUploadStatus = (typeof BlobUploadStatus)[keyof typeof BlobUploadStatus]
export const FileUploadConvertedStatus = <const>{
Queued: 0,
Converting: 1,
Completed: 2,
Error: 3
}
export type FileUploadConvertedStatus =
(typeof FileUploadConvertedStatus)[keyof typeof FileUploadConvertedStatus]
export const fileUploadConvertedStatusLabels: Record<
FileUploadConvertedStatus,
string
> = {
[FileUploadConvertedStatus.Queued]: 'Queued',
[FileUploadConvertedStatus.Converting]: 'Converting',
[FileUploadConvertedStatus.Completed]: 'Completed',
[FileUploadConvertedStatus.Error]: 'Error'
}
@@ -20,6 +20,10 @@ export default {
clickIcon: {
action: 'click',
type: 'function'
},
color: {
options: ['primary', 'secondary'],
control: { type: 'select' }
}
}
} as Meta
@@ -28,11 +28,7 @@ export const Default: StoryObj = {
},
template: `
<Table
:items="args.items"
:buttons="args.buttons"
:columns="args.columns"
:overflow-cells="args.overflowCells"
:on-row-click="args.onRowClick"
v-bind="args"
>
<template #name="{ item }">
<div class="flex items-center gap-2">
@@ -190,7 +186,8 @@ export const Default: StoryObj = {
],
overflowCells: false,
onRowClick: (item: unknown) => console.log('Row clicked', item),
roles: ['Admin', 'User', 'Guest']
roles: ['Admin', 'User', 'Guest'],
maxHeight: undefined
}
}
@@ -228,3 +225,11 @@ export const NoItems: StoryObj = {
items: []
}
}
export const WithLimitedHeight: StoryObj = {
...Default,
args: {
...Default.args,
maxHeight: 200
}
}
@@ -13,10 +13,7 @@
{{ column.header }}
</div>
</div>
<div
class="divide-y divide-outline-3 h-full overflow-visible"
:class="{ 'pb-32': overflowCells }"
>
<div :class="resultContainerClasses" :style="resultContainerStyle">
<div
v-if="loading || !items"
class="flex items-center justify-center py-3"
@@ -75,13 +72,14 @@
</slot>
</div>
</div>
<slot name="loader" />
</div>
</div>
</div>
</template>
<script setup lang="ts" generic="T extends {id: string}, C extends string">
import { noop, isString } from 'lodash'
import { computed } from 'vue'
import { computed, type CSSProperties } from 'vue'
import type { PropAnyComponent } from '~~/src/helpers/common/components'
import { CommonLoadingIcon, FormButton } from '~~/src/lib'
import { directive as vTippy } from 'vue-tippy'
@@ -111,10 +109,37 @@ const props = withDefaults(
rowItemsAlign?: 'center' | 'stretch'
emptyMessage?: string
loading?: boolean
maxHeight?: number
}>(),
{ rowItemsAlign: 'center', emptyMessage: 'No data found' }
)
const resultContainerClasses = computed(() => {
const classParts = ['divide-y divide-outline-3 overflow-visible']
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
})
+11 -19
View File
@@ -76,9 +76,9 @@ __metadata:
languageName: node
linkType: hard
"@apollo/client@npm:^3.12.4, @apollo/client@npm:^3.7.0, @apollo/client@npm:^3.8.0":
version: 3.12.4
resolution: "@apollo/client@npm:3.12.4"
"@apollo/client@npm:^3.13.8, @apollo/client@npm:^3.7.0, @apollo/client@npm:^3.8.0":
version: 3.13.8
resolution: "@apollo/client@npm:3.13.8"
dependencies:
"@graphql-typed-document-node/core": "npm:^3.1.1"
"@wry/caches": "npm:^1.0.0"
@@ -89,14 +89,13 @@ __metadata:
optimism: "npm:^0.18.0"
prop-types: "npm:^15.7.2"
rehackt: "npm:^0.1.0"
response-iterator: "npm:^0.2.6"
symbol-observable: "npm:^4.0.0"
ts-invariant: "npm:^0.10.3"
tslib: "npm:^2.3.0"
zen-observable-ts: "npm:^1.2.5"
peerDependencies:
graphql: ^15.0.0 || ^16.0.0
graphql-ws: ^5.5.5
graphql-ws: ^5.5.5 || ^6.0.3
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc
subscriptions-transport-ws: ^0.9.0 || ^0.11.0
@@ -109,7 +108,7 @@ __metadata:
optional: true
subscriptions-transport-ws:
optional: true
checksum: 10/9659ccf03ab5d6708cf191a1bd09c33ab5f958a8d66e783f81ccc899448b362a51cecf7d62c0ff5b852e60a340238e6903b3c96dcb5cf68dfb8a438ee6cd24bb
checksum: 10/a2fb3990ea25b96df2719ae0e925c07acc84cbf73da8871233169d65f01ae506cc0a52f6a83c40dea06bb3c9a5eb46a34f3e9b6e545931bae8c48f9bf4652e7a
languageName: node
linkType: hard
@@ -15748,7 +15747,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@speckle/frontend-2@workspace:packages/frontend-2"
dependencies:
"@apollo/client": "npm:^3.12.4"
"@apollo/client": "npm:^3.13.8"
"@artmizu/nuxt-prometheus": "npm:^2.2.1"
"@datadog/browser-rum": "npm:^5.11.0"
"@datadog/datadog-ci": "npm:^3.5.0"
@@ -15810,7 +15809,7 @@ __metadata:
"@types/ua-parser-js": "npm:^0.7.39"
"@typescript-eslint/eslint-plugin": "npm:^7.12.0"
"@typescript-eslint/parser": "npm:^7.12.0"
"@vue/apollo-composable": "npm:@speckle/apollo-composable@4.2.1-patch.1"
"@vue/apollo-composable": "npm:^4.2.2"
"@vue/apollo-ssr": "npm:4.0.0"
"@vueuse/core": "npm:^10.9.0"
apollo-upload-client: "npm:^18.0.1"
@@ -20677,9 +20676,9 @@ __metadata:
languageName: node
linkType: hard
"@vue/apollo-composable@npm:@speckle/apollo-composable@4.2.1-patch.1":
version: 4.2.1-patch.1
resolution: "@speckle/apollo-composable@npm:4.2.1-patch.1"
"@vue/apollo-composable@npm:^4.2.2":
version: 4.2.2
resolution: "@vue/apollo-composable@npm:4.2.2"
dependencies:
throttle-debounce: "npm:^5.0.0"
ts-essentials: "npm:^9.4.0"
@@ -20692,7 +20691,7 @@ __metadata:
peerDependenciesMeta:
"@vue/composition-api":
optional: true
checksum: 10/f8816269d1e0294b817af863417a49bce353e0324a7c984f9c71a057d4b3415e1708ac6bf41907951a7fe467cf0a1dfacfec9e78db9985ba7f2773cbba0a2e38
checksum: 10/1c26ac821016d79158812da0510e6da7148767e4f86f82cb0926391ecfee768c7238865e3bee14bf8a7a186f7ca8ec5c9586b3544bdc61909c52049c75ef3dd0
languageName: node
linkType: hard
@@ -43660,13 +43659,6 @@ __metadata:
languageName: node
linkType: hard
"response-iterator@npm:^0.2.6":
version: 0.2.6
resolution: "response-iterator@npm:0.2.6"
checksum: 10/ef7c74693ef3891461955a666e753585b298fe0de1baaf0d190e7a6818e4311e459d72f4a36f04aa8f49eda9b5f97124e5534be01e40d9e008795125d0bbb374
languageName: node
linkType: hard
"response-time@npm:^2.3.2":
version: 2.3.2
resolution: "response-time@npm:2.3.2"