Merge branch 'main' into fabians/core-ioc-85

This commit is contained in:
Kristaps Fabians Geikins
2024-10-18 16:12:21 +03:00
31 changed files with 331 additions and 112 deletions
@@ -6,7 +6,7 @@
:on-submit="onSubmit"
max-width="md"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="text-body-xs text-foreground font-medium">
How can we improve Speckle? If you have a feature request, please also share how
you would use it and why it's important to you
@@ -18,6 +18,13 @@
label="Feedback"
color="foundation"
/>
<p class="text-body-xs !leading-4">
Need help? For support, head over to our
<FormButton to="https://speckle.community/" target="_blank" link text>
community forum
</FormButton>
where we can chat and solve problems together.
</p>
</div>
</LayoutDialog>
</template>
@@ -9,16 +9,16 @@
viewBox="0 0 8 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="text-outline-2"
class="text-outline-5"
>
<path d="M2 18L6 6" stroke="currentColor" />
</svg>
</div>
<NuxtLink
:to="disableLink ? undefined : to"
class="flex gap-1 items-center text-body-xs ml-0.5 text-foreground-2 select-none truncate font-medium"
class="flex gap-1 items-center text-body-xs ml-0.5 text-foreground-2 select-none truncate"
:class="disableLink ? '' : 'hover:!text-foreground'"
active-class="group is-active !text-foreground"
active-class="group is-active !text-foreground font-medium"
>
<div class="truncate">
{{ name || to }}
@@ -98,7 +98,7 @@ const token = computed(
)
const mainClasses = computed(() => {
const classParts = [
'flex flex-col space-y-4 px-4 py-5 transition border-x border-b border-outline-2 first:border-t first:rounded-t-lg last:rounded-b-lg'
'flex flex-col space-y-4 px-4 py-5 transition bg-foundation border-x border-b border-outline-2 first:border-t first:rounded-t-lg last:rounded-b-lg'
]
if (props.block) {
@@ -1,9 +1,6 @@
<template>
<div>
<div
v-if="hasBanners"
class="bg-foundation divide-y divide-outline-3 mb-8 empty:mb-0"
>
<div v-if="hasBanners" class="space-y-3 mb-8 empty:mb-0">
<ProjectsInviteBanners
v-if="projectsInvites?.projectInvites?.length"
:invites="projectsInvites"
@@ -161,11 +161,11 @@ const showActionsMenu = ref<Record<string, boolean>>({})
const actionItems: LayoutMenuItem[][] = [
[
{
title: 'Change role',
title: 'Change role...',
id: ActionTypes.ChangeRole
},
{
title: 'Remove user',
title: 'Remove user...',
id: ActionTypes.RemoveUser
}
]
@@ -129,7 +129,7 @@ const invites = computed(() => result.value?.admin.inviteList.items || [])
const actionItems: LayoutMenuItem[][] = [
[
{ title: 'Resend invitation', id: 'resend-invite' },
{ title: 'Delete invitation', id: 'delete-invite' }
{ title: 'Delete invitation...', id: 'delete-invite' }
]
]
@@ -177,8 +177,8 @@ const showActionsMenu = ref<Record<string, boolean>>({})
const actionItems: LayoutMenuItem[][] = [
[
{ title: 'View project...', id: ActionTypes.ViewProject },
{ title: 'Edit members...', id: ActionTypes.EditMembers },
{ title: 'View project', id: ActionTypes.ViewProject },
{ title: 'Edit members', id: ActionTypes.EditMembers },
{ title: 'Remove project...', id: ActionTypes.RemoveProject }
]
]
@@ -290,6 +290,8 @@ const updateWorkspaceSlug = async (newSlug: string) => {
title: 'Workspace short ID updated'
})
showEditSlugDialog.value = false
slug.value = newSlug
if (route.params.slug === oldSlug) {
@@ -5,23 +5,36 @@
max-width="sm"
:buttons="dialogButtons"
>
<p class="text-body-xs text-foreground">
Are you sure you want to
<span class="font-medium">permanently delete</span>
the selected workspace?
</p>
<div
class="rounded border bg-foundation-2 border-outline-3 text-body-2xs text-foreground font-medium py-3 px-4 my-4"
>
{{ workspace.name }}
</div>
<p class="text-body-xs text-foreground">
This action
<span class="font-medium">cannot</span>
be undone.
<p class="text-body-xs text-foreground mb-2">
Are you sure you want to permanently delete
<span class="font-medium">{{ workspace.name }}?</span>
This action cannot be undone.
</p>
<FormTextInput
v-model="workspaceNameInput"
name="workspaceNameConfirm"
label="To confirm deletion, type the workspace name below."
placeholder="Type the workspace name here..."
full-width
show-label
hide-error-message
class="text-sm mb-2"
color="foundation"
/>
<FormTextArea
v-model="feedback"
name="reasonForDeletion"
label="Why did you delete this workspace?"
placeholder="We want to improve so we're curious about your honest feedback"
show-label
show-optional
full-width
class="text-sm mb-2"
color="foundation"
/>
</LayoutDialog>
</template>
<script setup lang="ts">
import { graphql } from '~~/lib/common/generated/gql'
import type {
@@ -29,7 +42,7 @@ import type {
UserWorkspacesArgs,
User
} from '~/lib/common/generated/gql/graphql'
import type { LayoutDialogButton } from '@speckle/ui-components'
import { FormTextInput, type LayoutDialogButton } from '@speckle/ui-components'
import { useMutation, useApolloClient } from '@vue/apollo-composable'
import { deleteWorkspaceMutation } from '~/lib/settings/graphql/mutations'
import {
@@ -43,6 +56,8 @@ import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { isUndefined } from 'lodash-es'
import { useMixpanel } from '~/lib/core/composables/mp'
import { homeRoute } from '~/lib/common/helpers/route'
import { useZapier } from '~/lib/core/composables/zapier'
import { useForm } from 'vee-validate'
graphql(`
fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {
@@ -63,10 +78,14 @@ const { activeUser } = useActiveUser()
const router = useRouter()
const apollo = useApolloClient().client
const mixpanel = useMixpanel()
const { sendWebhook } = useZapier()
const { resetForm } = useForm<{ feedback: string }>()
const workspaceNameInput = ref('')
const feedback = ref('')
const onDelete = async () => {
router.push(homeRoute)
isOpen.value = false
if (workspaceNameInput.value !== props.workspace.name) return
const cache = apollo.cache
const result = await deleteWorkspace({
@@ -98,16 +117,30 @@ const onDelete = async () => {
)
}
mixpanel.track('Workspace Deleted', {
// eslint-disable-next-line camelcase
workspace_id: props.workspace.id,
feedback: feedback.value
})
// Only send zapier-discord webhook if not in dev environment
if (!import.meta.dev) {
await sendWebhook('https://hooks.zapier.com/hooks/catch/12120532/2m4okri/', {
userId: activeUser.value?.id ?? '',
feedback: feedback.value
? `Action: Workspace Deleted(${props.workspace.name}) Feedback: ${feedback.value}`
: `Action: Workspace Deleted(${props.workspace.name}) - No feedback provided`
})
}
triggerNotification({
type: ToastNotificationType.Success,
title: 'Workspace deleted',
description: `The ${props.workspace.name} workspace has been deleted`
})
mixpanel.track('Workspace Deleted', {
// eslint-disable-next-line camelcase
workspace_id: props.workspace.id
})
router.push(homeRoute)
isOpen.value = false
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
@@ -129,9 +162,14 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Delete',
props: {
color: 'danger'
color: 'danger',
disabled: workspaceNameInput.value !== props.workspace.name
},
onClick: onDelete
}
])
watch(isOpen, () => {
resetForm()
})
</script>
@@ -19,11 +19,16 @@
<FormTextInput
v-model:model-value="workspaceShortId"
name="slug"
label="Short ID"
label="New short ID"
:help="`${baseUrl}${workspaceRoute(workspaceShortId)}`"
color="foundation"
:rules="[isStringOfLength({ maxLength: 50, minLength: 3 }), isValidWorkspaceSlug]"
:rules="[isStringOfLength({ maxLength: 50, minLength: 3 })]"
:custom-error-message="
workspaceShortId !== originalSlug ? error?.graphQLErrors[0]?.message : undefined
"
:loading="loading"
show-label
@update:model-value="updateDebouncedShortId"
/>
</LayoutDialog>
</template>
@@ -33,11 +38,11 @@ import { useForm } from 'vee-validate'
import { graphql } from '~~/lib/common/generated/gql'
import type { LayoutDialogButton } from '@speckle/ui-components'
import type { SettingsWorkspacesGeneralEditSlugDialog_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import {
isStringOfLength,
isValidWorkspaceSlug
} from '~~/lib/common/helpers/validation'
import { isStringOfLength } from '~~/lib/common/helpers/validation'
import { workspaceRoute } from '~/lib/common/helpers/route'
import { useQuery } from '@vue/apollo-composable'
import { validateWorkspaceSlugQuery } from '~/lib/workspaces/graphql/queries'
import { debounce } from 'lodash'
graphql(`
fragment SettingsWorkspacesGeneralEditSlugDialog_Workspace on Workspace {
@@ -57,13 +62,27 @@ const emit = defineEmits<{
(e: 'update:slug', newSlug: string): void
}>()
const { handleSubmit } = useForm<{ slug: string }>()
// Main ref that holds the current value of the slug input.
const workspaceShortId = ref(props.workspace.slug)
// Used to debounce API calls for slug validation.
const debouncedWorkspaceShortId = ref(props.workspace.slug)
// Keeps track of the initially generated slug to prevent unnecessary validations.
const originalSlug = ref(props.workspace.slug)
const { error, loading } = useQuery(
validateWorkspaceSlugQuery,
() => ({
slug: debouncedWorkspaceShortId.value
}),
() => ({
enabled: debouncedWorkspaceShortId.value !== props.workspace.slug
})
)
const { handleSubmit, resetForm } = useForm<{ slug: string }>()
const updateSlug = handleSubmit(() => {
emit('update:slug', workspaceShortId.value)
isOpen.value = false
})
const dialogButtons = computed((): LayoutDialogButton[] => [
@@ -78,12 +97,16 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
text: 'Update',
props: {
color: 'primary',
disabled: workspaceShortId.value === props.workspace.slug
disabled: workspaceShortId.value === props.workspace.slug || error.value !== null
},
submit: true
}
])
const updateDebouncedShortId = debounce((value: string) => {
debouncedWorkspaceShortId.value = value
}, 300)
watch(
() => props.workspace.slug,
(newValue) => {
@@ -91,4 +114,14 @@ watch(
},
{ immediate: true }
)
watch(
() => isOpen.value,
(newValue) => {
if (!newValue) {
resetForm()
error.value = null
}
}
)
</script>
@@ -23,12 +23,11 @@
label="Short ID"
:help="getShortIdHelp"
color="foundation"
:rules="[
isStringOfLength({ maxLength: 50, minLength: 3 }),
isValidWorkspaceSlug
]"
:loading="loading"
:rules="isStringOfLength({ maxLength: 50, minLength: 3 })"
:custom-error-message="error?.graphQLErrors[0]?.message"
show-label
@update:model-value="shortIdManuallyEdited = true"
@update:model-value="onSlugChange"
/>
<UserAvatarEditable
v-model:edit-mode="editAvatarMode"
@@ -49,13 +48,11 @@ import type { MaybeNullOrUndefined } from '@speckle/shared'
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useCreateWorkspace } from '~/lib/workspaces/composables/management'
import { useWorkspacesAvatar } from '~/lib/workspaces/composables/avatar'
import {
isRequired,
isStringOfLength,
isValidWorkspaceSlug
} from '~~/lib/common/helpers/validation'
import { isRequired, isStringOfLength } from '~~/lib/common/helpers/validation'
import { generateSlugFromName } from '@speckle/shared'
import { debounce } from 'lodash'
import { useQuery } from '@vue/apollo-composable'
import { validateWorkspaceSlugQuery } from '~/lib/workspaces/graphql/queries'
const emit = defineEmits<(e: 'created') => void>()
@@ -69,15 +66,25 @@ const isOpen = defineModel<boolean>('open', { required: true })
const createWorkspace = useCreateWorkspace()
const { generateDefaultLogoIndex, getDefaultAvatar } = useWorkspacesAvatar()
const { handleSubmit } = useForm<{ name: string; slug: string }>()
const { handleSubmit, resetForm } = useForm<{ name: string; slug: string }>()
const workspaceName = ref('')
const workspaceShortId = ref('')
const debouncedWorkspaceShortId = ref('')
const editAvatarMode = ref(false)
const workspaceLogo = ref<MaybeNullOrUndefined<string>>()
const defaultLogoIndex = ref(0)
const shortIdManuallyEdited = ref(false)
const customShortIdError = ref('')
const { error, loading } = useQuery(
validateWorkspaceSlugQuery,
() => ({
slug: debouncedWorkspaceShortId.value
}),
() => ({
enabled: !!debouncedWorkspaceShortId.value
})
)
const baseUrl = useRuntimeConfig().public.baseUrl
@@ -105,7 +112,7 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
disabled:
!workspaceName.value.trim() ||
!workspaceShortId.value.trim() ||
!!customShortIdError.value
error.value !== null
}
}
])
@@ -122,7 +129,7 @@ const handleCreateWorkspace = handleSubmit(async () => {
{ source: props.eventSource }
)
if (newWorkspace) {
if (newWorkspace && !newWorkspace?.errors) {
emit('created')
isOpen.value = false
}
@@ -135,21 +142,36 @@ const onLogoSave = (newVal: MaybeNullOrUndefined<string>) => {
const reset = () => {
defaultLogoIndex.value = generateDefaultLogoIndex()
workspaceName.value = ''
workspaceShortId.value = ''
debouncedWorkspaceShortId.value = ''
workspaceLogo.value = null
editAvatarMode.value = false
shortIdManuallyEdited.value = false
customShortIdError.value = ''
error.value = null
}
const updateShortId = debounce((newName: string) => {
if (!shortIdManuallyEdited.value) {
workspaceShortId.value = generateSlugFromName({ name: newName })
const newSlug = generateSlugFromName({ name: newName })
workspaceShortId.value = newSlug
updateDebouncedShortId(newSlug)
}
}, 600)
const updateDebouncedShortId = debounce((newSlug: string) => {
debouncedWorkspaceShortId.value = newSlug
}, 300)
const onSlugChange = (newSlug: string) => {
workspaceShortId.value = newSlug
shortIdManuallyEdited.value = true
updateDebouncedShortId(newSlug)
}
// Seperate resets to avoid a temporary invalid state on submission
watch(isOpen, (newVal) => {
if (newVal) reset()
if (!newVal) {
reset()
resetForm()
}
})
</script>
@@ -29,7 +29,7 @@
<span
v-tippy="
project.role !== Roles.Stream.Owner &&
'Only project owners can move projects'
'Only the project owner can move this project'
"
>
<FormButton
@@ -44,7 +44,7 @@
</div>
</div>
<p v-else class="py-4 text-body-xs text-foreground-2">
You don't have any projects that are moveable to this workspace
You don't have any projects that can be moved into this workspace
</p>
<ProjectsMoveToWorkspaceDialog
@@ -1,5 +1,5 @@
<template>
<div class="w-36 flex flex-col items-center md:mx-4">
<div class="w-40 flex flex-col items-center md:mx-4">
<CommonProgressBar
class="mb-1"
:current-value="versionsCount.current"
@@ -325,6 +325,7 @@ const documents = {
"\n query WorkspaceProjectsQuery(\n $workspaceSlug: String!\n $filter: WorkspaceProjectsFilter\n $cursor: String\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n projects(filter: $filter, cursor: $cursor, limit: 10) {\n ...WorkspaceProjectList_ProjectCollection\n }\n }\n }\n": types.WorkspaceProjectsQueryDocument,
"\n query WorkspaceInvite(\n $workspaceId: String\n $token: String\n $options: WorkspaceInviteLookupOptions\n ) {\n workspaceInvite(workspaceId: $workspaceId, token: $token, options: $options) {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n ...WorkspaceInviteBlock_PendingWorkspaceCollaborator\n }\n }\n": types.WorkspaceInviteDocument,
"\n query MoveProjectsDialog {\n activeUser {\n ...MoveProjectsDialog_User\n }\n }\n": types.MoveProjectsDialogDocument,
"\n query ValidateWorkspaceSlug($slug: String!) {\n validateWorkspaceSlug(slug: $slug)\n }\n": types.ValidateWorkspaceSlugDocument,
"\n query LegacyBranchRedirectMetadata($streamId: String!, $branchName: String!) {\n project(id: $streamId) {\n modelByName(name: $branchName) {\n id\n }\n }\n }\n": types.LegacyBranchRedirectMetadataDocument,
"\n query LegacyViewerCommitRedirectMetadata($streamId: String!, $commitId: String!) {\n project(id: $streamId) {\n version(id: $commitId) {\n id\n model {\n id\n }\n }\n }\n }\n": types.LegacyViewerCommitRedirectMetadataDocument,
"\n query LegacyViewerStreamRedirectMetadata($streamId: String!) {\n project(id: $streamId) {\n id\n versions(limit: 1) {\n totalCount\n items {\n id\n model {\n id\n }\n }\n }\n }\n }\n": types.LegacyViewerStreamRedirectMetadataDocument,
@@ -1601,6 +1602,10 @@ export function graphql(source: "\n query WorkspaceInvite(\n $workspaceId: S
* 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 MoveProjectsDialog {\n activeUser {\n ...MoveProjectsDialog_User\n }\n }\n"): (typeof documents)["\n query MoveProjectsDialog {\n activeUser {\n ...MoveProjectsDialog_User\n }\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 ValidateWorkspaceSlug($slug: String!) {\n validateWorkspaceSlug(slug: $slug)\n }\n"): (typeof documents)["\n query ValidateWorkspaceSlug($slug: String!) {\n validateWorkspaceSlug(slug: $slug)\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
@@ -72,3 +72,9 @@ export const moveProjectsDialogQuery = graphql(`
}
}
`)
export const validateWorkspaceSlugQuery = graphql(`
query ValidateWorkspaceSlug($slug: String!) {
validateWorkspaceSlug(slug: $slug)
}
`)
@@ -171,10 +171,10 @@ const actionsItems = computed<LayoutMenuItem[][]>(() => {
if (isWorkspacesEnabled.value && !project.value?.workspace?.id) {
items.push([
{
title: 'Move...',
title: 'Move project...',
id: ActionTypes.Move,
disabled: !isOwner.value,
disabledTooltip: 'Only project owners can move projects into workspaces'
disabledTooltip: 'Only the project owner can move this project into a workspace'
}
])
}
@@ -39,7 +39,7 @@ extend type Project {
"""
Retrieve a specific project version by its ID
"""
version(id: String!): Version
version(id: String!): Version!
"""
Retrieve a specific project model by its ID
@@ -1836,7 +1836,7 @@ export type Project = {
team: Array<ProjectCollaborator>;
updatedAt: Scalars['DateTime']['output'];
/** Retrieve a specific project version by its ID */
version?: Maybe<Version>;
version: Version;
/** Returns a flat list of all project versions */
versions: VersionCollection;
/** Return metadata about resources being requested in the viewer */
@@ -5508,7 +5508,7 @@ export type ProjectResolvers<ContextType = GraphQLContext, ParentType extends Re
sourceApps?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
team?: Resolver<Array<ResolversTypes['ProjectCollaborator']>, ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
version?: Resolver<Maybe<ResolversTypes['Version']>, ParentType, ContextType, RequireFields<ProjectVersionArgs, 'id'>>;
version?: Resolver<ResolversTypes['Version'], ParentType, ContextType, RequireFields<ProjectVersionArgs, 'id'>>;
versions?: Resolver<ResolversTypes['VersionCollection'], ParentType, ContextType, RequireFields<ProjectVersionsArgs, 'limit'>>;
viewerResources?: Resolver<Array<ResolversTypes['ViewerResourceGroup']>, ParentType, ContextType, RequireFields<ProjectViewerResourcesArgs, 'loadedVersionsOnly' | 'resourceIdString'>>;
visibility?: Resolver<ResolversTypes['ProjectVisibility'], ParentType, ContextType>;
@@ -11,7 +11,7 @@ import {
batchDeleteCommits,
batchMoveCommitsFactory
} from '@/modules/core/services/commit/batchCommitActions'
import { CommitUpdateError } from '@/modules/core/errors/commit'
import { CommitNotFoundError, CommitUpdateError } from '@/modules/core/errors/commit'
import {
createCommitByBranchIdFactory,
markCommitReceivedAndNotify,
@@ -106,9 +106,14 @@ const batchMoveCommits = batchMoveCommitsFactory({
export = {
Project: {
async version(parent, args, ctx) {
return await ctx.loaders.streams.getStreamCommit
const version = await ctx.loaders.streams.getStreamCommit
.forStream(parent.id)
.load(args.id)
if (!version) {
throw new CommitNotFoundError('Version not found')
}
return version
}
},
Version: {
@@ -1820,7 +1820,7 @@ export type Project = {
team: Array<ProjectCollaborator>;
updatedAt: Scalars['DateTime']['output'];
/** Retrieve a specific project version by its ID */
version?: Maybe<Version>;
version: Version;
/** Returns a flat list of all project versions */
versions: VersionCollection;
/** Return metadata about resources being requested in the viewer */
@@ -1821,7 +1821,7 @@ export type Project = {
team: Array<ProjectCollaborator>;
updatedAt: Scalars['DateTime']['output'];
/** Retrieve a specific project version by its ID */
version?: Maybe<Version>;
version: Version;
/** Returns a flat list of all project versions */
versions: VersionCollection;
/** Return metadata about resources being requested in the viewer */
@@ -1,8 +1,25 @@
<template>
<ArrowPathIcon :class="iconClasses" />
<svg
class="spinner"
:class="iconClasses"
width="32px"
height="40px"
viewBox="0 0 66 66"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="path"
fill="none"
stroke="currentColor"
stroke-width="6"
stroke-linecap="round"
cx="33"
cy="33"
r="30"
></circle>
</svg>
</template>
<script setup lang="ts">
import { ArrowPathIcon } from '@heroicons/vue/24/solid'
import { computed } from 'vue'
type Size = 'base' | 'sm' | 'lg'
@@ -13,21 +30,57 @@ const props = withDefaults(defineProps<{ loading?: boolean; size?: Size }>(), {
})
const iconClasses = computed(() => {
const classParts: string[] = ['text-primary transition-all animate-spin']
const classParts: string[] = ['']
classParts.push(props.loading ? 'opacity-100' : 'opacity-0')
switch (props.size) {
case 'base':
classParts.push('h-8 w-8')
break
case 'sm':
classParts.push('h-5 w-5')
break
case 'sm':
classParts.push('h-4 w-4')
break
case 'lg':
classParts.push('h-12 w-12')
classParts.push('h-8 w-8')
break
}
return classParts.join(' ')
})
</script>
<style scoped>
@keyframes rotator {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(270deg);
}
}
@keyframes dash {
0% {
stroke-dashoffset: 187;
}
50% {
stroke-dashoffset: 46.75;
transform: rotate(135deg);
}
100% {
stroke-dashoffset: 187;
transform: rotate(450deg);
}
}
.spinner {
animation: rotator 1.4s linear infinite;
}
.path {
stroke-dasharray: 187;
stroke-dashoffset: 0;
transform-origin: center;
animation: dash 1.4s ease-in-out infinite;
}
</style>
@@ -25,8 +25,8 @@ import { isObjectLike } from 'lodash'
import type { PropAnyComponent } from '~~/src/helpers/common/components'
import { computed, resolveDynamicComponent } from 'vue'
import type { Nullable } from '@speckle/shared'
import { ArrowPathIcon } from '@heroicons/vue/24/solid'
import type { FormButtonStyle, FormButtonSize } from '~~/src/helpers/form/button'
import { CommonLoadingIcon } from '~~/src/lib'
const emit = defineEmits<{
/**
@@ -122,7 +122,9 @@ const buttonType = computed(() => {
})
const isDisabled = computed(() => props.disabled || props.loading)
const finalLeftIcon = computed(() => (props.loading ? ArrowPathIcon : props.iconLeft))
const finalLeftIcon = computed(() =>
props.loading ? CommonLoadingIcon : props.iconLeft
)
const bgAndBorderClasses = computed(() => {
const classParts: string[] = []
@@ -266,10 +268,6 @@ const buttonClasses = computed(() => {
const iconClasses = computed(() => {
const classParts: string[] = ['shrink-0']
if (props.loading) {
classParts.push('animate-spin')
}
switch (props.size) {
case 'sm':
classParts.push('h-5 w-5 p-0.5')
@@ -154,6 +154,14 @@ export const LabelLeft = mergeStories(Default, {
}
})
export const Loading = mergeStories(Default, {
args: {
name: generateRandomName('loading'),
label: 'With loading spinner',
loading: true
}
})
export const WithCustomRightSlot = mergeStories(Default, {
render: (args) => ({
components: { FormTextInput, FormButton },
@@ -37,6 +37,13 @@
aria-hidden="true"
/>
</div>
<div
v-if="loading"
class="absolute top-0 h-full right-0 flex items-center pr-2 text-foreground-3"
>
<CommonLoadingIcon />
</div>
<input
:id="name"
ref="inputElement"
@@ -115,6 +122,7 @@ import { useTextInputCore } from '~~/src/composables/form/textInput'
import type { PropAnyComponent } from '~~/src/helpers/common/components'
import type { InputColor } from '~~/src/composables/form/textInput'
import type { LabelPosition } from '~~/src/composables/form/input'
import { CommonLoadingIcon } from '~~/src/lib'
type InputType = 'text' | 'email' | 'password' | 'url' | 'search' | 'number' | string
type InputSize = 'sm' | 'base' | 'lg' | 'xl'
@@ -256,10 +264,18 @@ const props = defineProps({
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
hideErrorMessage: {
type: Boolean,
default: false
},
customErrorMessage: {
type: String,
default: null
},
wrapperClasses: {
type: String,
default: () => ''
@@ -26,6 +26,7 @@ export function useTextInputCore<V extends string | string[] = string>(params: {
autoFocus?: boolean
showClear?: boolean
useLabelInErrors?: boolean
customErrorMessage?: string
hideErrorMessage?: boolean
color?: InputColor
labelPosition?: LabelPosition
@@ -41,11 +42,15 @@ export function useTextInputCore<V extends string | string[] = string>(params: {
}) {
const { props, inputEl, emit, options } = params
const { value, errorMessage: error } = useField<V>(props.name, props.rules, {
validateOnMount: unref(props.validateOnMount),
validateOnValueUpdate: unref(props.validateOnValueUpdate),
initialValue: unref(props.modelValue) || undefined
})
const { value, errorMessage: veeErrorMessage } = useField<V>(
props.name,
props.rules,
{
validateOnMount: unref(props.validateOnMount),
validateOnValueUpdate: unref(props.validateOnValueUpdate),
initialValue: unref(props.modelValue) || undefined
}
)
const labelClasses = computed(() => {
const classParts = [
@@ -76,7 +81,7 @@ export function useTextInputCore<V extends string | string[] = string>(params: {
coreInputClasses.value
]
if (error.value) {
if (hasError.value) {
classParts.push('!border-danger')
} else {
classParts.push('border-0 focus:ring-2 focus:ring-outline-2')
@@ -99,12 +104,19 @@ export function useTextInputCore<V extends string | string[] = string>(params: {
const internalHelpTipId = ref(nanoid())
const title = computed(() => unref(props.label) || unref(props.name))
const errorMessage = computed(() => {
const base = error.value
if (unref(props.customErrorMessage)) {
return unref(props.customErrorMessage)
}
const base = veeErrorMessage.value
if (!base || !unref(props.useLabelInErrors)) return base
return base.replace('Value', title.value)
})
const hasError = computed(() => !!errorMessage.value)
const hideHelpTip = computed(
() => errorMessage.value && unref(props.hideErrorMessage)
)
@@ -115,7 +127,7 @@ export function useTextInputCore<V extends string | string[] = string>(params: {
)
const helpTipClasses = computed((): string => {
const classParts = ['text-body-2xs break-words']
classParts.push(error.value ? 'text-danger' : 'text-foreground-2')
classParts.push(hasError.value ? 'text-danger' : 'text-foreground-2')
return classParts.join(' ')
})
const shouldShowClear = computed(() => {
@@ -154,7 +166,8 @@ export function useTextInputCore<V extends string | string[] = string>(params: {
clear,
focus,
labelClasses,
shouldShowClear
shouldShowClear,
hasError
}
}
+3 -3
View File
@@ -1357,11 +1357,11 @@ export default class Sandbox {
url.includes('latest') ? 'AuthTokenLatest' : 'AuthToken'
) as string
const objUrls = await UrlHelper.getResourceUrls(url, authToken)
for (const url of objUrls) {
for (const objUrl of objUrls) {
console.log(`Loading ${url}`)
const loader = new SpeckleLoader(
this.viewer.getWorldTree(),
url,
objUrl,
authToken,
true,
undefined
@@ -1377,7 +1377,7 @@ export default class Sandbox {
console.error(`Loader warning: ${arg.message}`)
})
await this.viewer.loadObject(loader, true)
void this.viewer.loadObject(loader, true)
}
localStorage.setItem('last-load-url', url)
}
+3 -2
View File
@@ -105,7 +105,7 @@ const getStream = () => {
// prettier-ignore
// 'https://app.speckle.systems/streams/da9e320dad/commits/5388ef24b8?c=%5B-7.66134,10.82932,6.41935,-0.07739,-13.88552,1.8697,0,1%5D'
// Revit sample house (good for bim-like stuff with many display meshes)
// 'https://app.speckle.systems/streams/da9e320dad/commits/5388ef24b8'
'https://app.speckle.systems/streams/da9e320dad/commits/5388ef24b8'
// 'https://latest.speckle.systems/streams/c1faab5c62/commits/ab1a1ab2b6'
// 'https://app.speckle.systems/streams/da9e320dad/commits/5388ef24b8'
// 'https://latest.speckle.systems/streams/58b5648c4d/commits/60371ecb2d'
@@ -424,7 +424,8 @@ const getStream = () => {
// 'https://app.speckle.systems/projects/00a5c443d6/models/de56edf901'
'https://app.speckle.systems/projects/6cf358a40e/models/e01ffbc891@f1ddc19011'
// 'https://app.speckle.systems/projects/6cf358a40e/models/e01ffbc891@f1ddc19011'
// 'https://latest.speckle.systems/projects/2c5c4cd493/models/cb20c2e87b02003d4b2ddf4df96912b9,8a2c9a45093fecb42273607d0c936d66'
)
}
@@ -74,7 +74,7 @@ export default class Batcher {
await pause.wait(50)
}
let instancedNodes = worldTree.findId(g)
let instancedNodes = worldTree.findId(g, renderTree.subtreeId)
if (!instancedNodes) {
continue
}
@@ -99,7 +99,7 @@ export default class Batcher {
}
for (const v in instancedBatches) {
for (let k = 0; k < instancedBatches[v].length; k++) {
const nodes = worldTree.findId(instancedBatches[v][k])
const nodes = worldTree.findId(instancedBatches[v][k], renderTree.subtreeId)
if (!nodes) continue
/** Make sure entire instance set is instanced */
let instanced = true
@@ -15,6 +15,10 @@ export class RenderTree {
return this.root.model.id
}
public get subtreeId(): number {
return this.root.model.subtreeId
}
public constructor(tree: WorldTree, subtreeRoot: TreeNode) {
this.tree = tree
this.root = subtreeRoot
@@ -192,8 +196,11 @@ export class RenderTree {
})
}
public getRenderViewsForNodeId(id: string): NodeRenderView[] | null {
const nodes = this.tree.findId(id)
public getRenderViewsForNodeId(
id: string,
subtreeId?: number
): NodeRenderView[] | null {
const nodes = this.tree.findId(id, subtreeId)
if (!nodes) {
Logger.warn(`Id ${id} does not exist`)
return null