356 lines
12 KiB
Vue
356 lines
12 KiB
Vue
<template>
|
|
<div v-if="projectDetails" class="px-[2px] rounded-md">
|
|
<button
|
|
:class="`flex w-full items-center text-foreground-2 justify-between hover:bg-foundation-2 ${
|
|
showModels ? 'bg-foundation-2' : 'bg-foundation-2'
|
|
} rounded-md transition group`"
|
|
@click="showModels = !showModels"
|
|
>
|
|
<div class="flex items-center transition group-hover:text-primary h-8 min-w-0">
|
|
<CommonIconsArrowFilled
|
|
:class="`w-5 ${showModels ? '' : '-rotate-90'} transition`"
|
|
/>
|
|
<div class="text-sm text-left truncate select-none flex items-center leading-1">
|
|
<div class="text-heading-sm">{{ projectDetails.name }}</div>
|
|
<div v-if="!showModels" class="text-body-3xs opacity-50 ml-2 pt-[1px]">
|
|
{{ project.senders.length + project.receivers.length }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
:class="
|
|
isPersonalProject ? '' : 'opacity-0 group-hover:opacity-100 transition flex'
|
|
"
|
|
>
|
|
<button
|
|
v-tippy="projectNavigatorTippy"
|
|
class="hover:text-primary flex items-center space-x-2 p-2 relative animate-pulse"
|
|
>
|
|
<div class="relative w-4 h-4">
|
|
<ArrowTopRightOnSquareIcon
|
|
class="w-4 h-4"
|
|
@click.stop="
|
|
$openUrl(projectUrl),
|
|
trackEvent('DUI3 Action', { name: 'Project View' }, project.accountId)
|
|
"
|
|
/>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</button>
|
|
|
|
<div v-show="showModels" class="space-y-2 mt-2 pb-1">
|
|
<CommonAlert
|
|
v-if="isWorkspaceReadOnly"
|
|
size="xs"
|
|
:color="'warning'"
|
|
:actions="[
|
|
{
|
|
title: 'Subscribe',
|
|
onClick: () => $openUrl(workspaceUrl)
|
|
}
|
|
]"
|
|
>
|
|
<template #description>
|
|
The workspace is in a read-only locked state until there's an active
|
|
subscription. Subscribe to a plan to regain full access.
|
|
</template>
|
|
</CommonAlert>
|
|
<ModelSender
|
|
v-for="model in project.senders"
|
|
:key="model.modelCardId"
|
|
:model-card="model"
|
|
:project="project"
|
|
:can-edit="canPublish"
|
|
/>
|
|
<ModelReceiver
|
|
v-for="model in project.receivers"
|
|
:key="model.modelCardId"
|
|
:model-card="model"
|
|
:project="project"
|
|
:can-edit="canLoad"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="projectIsAccesible === false"
|
|
class="px-2 py-4 bg-foundation dark:bg-neutral-700/10 rounded-md shadow"
|
|
>
|
|
<CommonAlert
|
|
color="danger"
|
|
with-dismiss
|
|
@dismiss="askDismissProjectQuestionDialog = true"
|
|
>
|
|
<template #title>
|
|
<span v-if="inaccessibleReason === 'no-account'">
|
|
<!-- no local account matches this project's server — query was never attempted -->
|
|
No account found for
|
|
<code>{{ project.serverUrl }}</code>
|
|
</span>
|
|
<span v-else>
|
|
<!-- project query threw — server will throw for any reason the account can't see
|
|
the project (deleted, private, auth failure, stale token). model card level
|
|
permission errors e.g. role changes surface separately on the card itself -->
|
|
Project
|
|
<code>{{ project.projectId }}</code>
|
|
is not accessible on
|
|
<code>{{ project.serverUrl }}</code>
|
|
</span>
|
|
</template>
|
|
</CommonAlert>
|
|
<CommonDialog v-model:open="askDismissProjectQuestionDialog" fullscreen="none">
|
|
<template #header>Remove Project</template>
|
|
<div class="text-xs mb-4">Do you want to remove the project from this file?</div>
|
|
<div class="flex justify-between center py-2 space-x-3">
|
|
<FormButton size="sm" full-width @click="removeProjectModels">
|
|
Remove
|
|
</FormButton>
|
|
<FormButton
|
|
size="sm"
|
|
full-width
|
|
@click="askDismissProjectQuestionDialog = false"
|
|
>
|
|
Cancel
|
|
</FormButton>
|
|
</div>
|
|
</CommonDialog>
|
|
</div>
|
|
</template>
|
|
<script setup lang="ts">
|
|
import { useQuery, useSubscription } from '@vue/apollo-composable'
|
|
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/20/solid'
|
|
import type { ProjectModelGroup } from '~~/store/hostApp'
|
|
import { useHostAppStore } from '~~/store/hostApp'
|
|
import { useAccountStore } from '~~/store/accounts'
|
|
import {
|
|
projectDetailsQuery,
|
|
versionCreatedSubscription,
|
|
userProjectsUpdatedSubscription,
|
|
projectUpdatedSubscription
|
|
} from '~~/lib/graphql/mutationsAndQueries'
|
|
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
|
|
|
const { trackEvent } = useMixpanel()
|
|
const accountStore = useAccountStore()
|
|
const hostAppStore = useHostAppStore()
|
|
const { $openUrl } = useNuxtApp()
|
|
|
|
const props = defineProps<{
|
|
project: ProjectModelGroup
|
|
}>()
|
|
|
|
const showModels = ref(true)
|
|
const askDismissProjectQuestionDialog = ref(false)
|
|
const writeAccessRequested = ref(false)
|
|
const projectIsAccesible = ref<boolean | undefined>(undefined)
|
|
const inaccessibleReason = ref<'no-account' | 'error' | undefined>(undefined)
|
|
|
|
const projectAccount = computed(() =>
|
|
accountStore.accountWithFallback(props.project.accountId, props.project.serverUrl)
|
|
)
|
|
|
|
const isPersonalProject = computed(() => !projectDetails.value?.workspace)
|
|
const projectNavigatorTippy = computed(() =>
|
|
isPersonalProject.value
|
|
? 'Move personal project into a workspace'
|
|
: 'Open project in browser'
|
|
)
|
|
|
|
const normalizeUrl = (url: string) => url.replace(/\/$/, '').toLowerCase()
|
|
|
|
// match by account ID first, then fall back to server URL
|
|
// normalized to avoid trailing-slash / casing mismatches (can that even be a thing??)
|
|
const accountExists = computed(() => {
|
|
const byId = accountStore.isAccountExistsById(props.project.accountId)
|
|
const byServer = accountStore.accounts.some(
|
|
(acc) =>
|
|
normalizeUrl(acc.accountInfo.serverInfo.url) ===
|
|
normalizeUrl(props.project.serverUrl)
|
|
)
|
|
return byId || byServer
|
|
})
|
|
|
|
// reactive so it re-derives if accounts change after mount (e.g. user adds the missing account)
|
|
const clientId = computed(() => projectAccount.value.accountInfo.id)
|
|
|
|
const {
|
|
result: projectDetailsResult,
|
|
refetch: refetchProjectDetails,
|
|
onError: onProjectDetailsError
|
|
} = useQuery(
|
|
projectDetailsQuery,
|
|
() => ({ projectId: props.project.projectId }),
|
|
() => ({
|
|
clientId: clientId.value,
|
|
debounce: 500,
|
|
fetchPolicy: 'network-only',
|
|
enabled: accountExists.value
|
|
})
|
|
)
|
|
|
|
// re-run the query when the resolved account changes (account added) or
|
|
// when accountExists flips back to true (account re-added after removal)
|
|
watch([clientId, accountExists], ([, exists], [, prevExists]) => {
|
|
if (exists) {
|
|
if (!prevExists) {
|
|
// accountExists just recovered — reset accessible state so we don't
|
|
// show stale inaccessible UI while the query is in flight
|
|
projectIsAccesible.value = undefined
|
|
}
|
|
void refetchProjectDetails()
|
|
}
|
|
})
|
|
|
|
const removeProjectModels = async () => {
|
|
await hostAppStore.removeProjectModels(props.project.projectId)
|
|
askDismissProjectQuestionDialog.value = false
|
|
}
|
|
|
|
const projectDetails = computed(() => projectDetailsResult.value?.project)
|
|
|
|
watch(
|
|
[projectDetails, accountExists],
|
|
([details, exists]) => {
|
|
if (!exists) {
|
|
// account not present on this machine at all
|
|
console.warn('[ProjectModelGroup] inaccessible: no matching account', {
|
|
projectId: props.project.projectId,
|
|
storedAccountId: props.project.accountId,
|
|
storedServerUrl: props.project.serverUrl,
|
|
localAccounts: accountStore.accounts.map((a) => ({
|
|
id: a.accountInfo.id,
|
|
serverUrl: a.accountInfo.serverInfo.url
|
|
}))
|
|
})
|
|
inaccessibleReason.value = 'no-account'
|
|
projectIsAccesible.value = false
|
|
} else if (details !== undefined) {
|
|
// query returned real data — project is accessible
|
|
inaccessibleReason.value = undefined
|
|
projectIsAccesible.value = true
|
|
}
|
|
// undefined means the query is still loading; don't update state yet
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
onProjectDetailsError((error) => {
|
|
// the project query throws for any reason the account can't see the project —
|
|
// deleted, private, auth failure, network error. all cases show the same message.
|
|
console.warn('[ProjectModelGroup] inaccessible: project query errored', {
|
|
projectId: props.project.projectId,
|
|
accountId: props.project.accountId,
|
|
error: error.message
|
|
})
|
|
inaccessibleReason.value = 'error'
|
|
projectIsAccesible.value = false
|
|
})
|
|
|
|
// when the account's validity changes (e.g. SSO session deleted externally), refetch
|
|
// so the query hits the server and picks up the new auth state
|
|
const accountIsValid = computed(
|
|
() => accountStore.accounts.find((a) => a.accountInfo.id === clientId.value)?.isValid
|
|
)
|
|
|
|
watch(accountIsValid, (isValid, wasValid) => {
|
|
if (wasValid !== undefined && isValid !== wasValid) {
|
|
void refetchProjectDetails()
|
|
}
|
|
})
|
|
|
|
const canLoad = computed(() => !!projectDetails.value?.permissions.canLoad.authorized)
|
|
const canPublish = computed(
|
|
() => !!projectDetails.value?.permissions.canPublish.authorized
|
|
)
|
|
|
|
const isWorkspaceReadOnly = computed(() => {
|
|
if (!projectDetails.value?.workspace) return false // project is not even in a workspace
|
|
return projectDetails.value?.workspace?.readOnly
|
|
})
|
|
|
|
// Enable later when FE2 is ready for accepting/denying requested accesses
|
|
// const hasServerMatch = computed(() =>
|
|
// accountStore.isAccountExistsByServer(props.project.serverUrl)
|
|
// )
|
|
|
|
// const requestWriteAccess = async () => {
|
|
// if (hasServerMatch.value) {
|
|
// const { mutate } = provideApolloClient((projectAccount.value as DUIAccount).client)(
|
|
// () => useMutation(requestProjectAccess)
|
|
// )
|
|
// const res = await mutate({
|
|
// input: projectDetails.value?.id as string
|
|
// })
|
|
// writeAccessRequested.value = true
|
|
// // TODO: It throws if it has already pending request, handle it!
|
|
// console.log(res)
|
|
// }
|
|
// }
|
|
|
|
const { onResult: userProjectsUpdated } = useSubscription(
|
|
userProjectsUpdatedSubscription,
|
|
() => ({}),
|
|
() => ({ clientId: clientId.value, enabled: accountExists.value })
|
|
)
|
|
|
|
const { onResult: projectUpdated } = useSubscription(
|
|
projectUpdatedSubscription,
|
|
() => ({ projectId: props.project.projectId }),
|
|
() => ({ clientId: clientId.value, enabled: accountExists.value })
|
|
)
|
|
|
|
// to catch changes on visibility of project
|
|
projectUpdated((res) => {
|
|
// TODO: FIX needed: whenever project visibility changed from "discoverable" to "private", we can't get message if the `clientId` is not part of the team
|
|
// validated with Fabians this is a current behavior.
|
|
if (!res.data) return
|
|
refetchProjectDetails()
|
|
})
|
|
|
|
// to catch changes on team of the project
|
|
userProjectsUpdated((res) => {
|
|
if (!res.data) return
|
|
refetchProjectDetails()
|
|
writeAccessRequested.value = false
|
|
})
|
|
|
|
const projectUrl = computed(() => {
|
|
const acc = accountStore.accounts.find((acc) => acc.accountInfo.id === clientId.value)
|
|
return `${acc?.accountInfo.serverInfo.url as string}/projects/${
|
|
props.project.projectId
|
|
}`
|
|
})
|
|
|
|
const workspaceUrl = computed(() => {
|
|
const acc = accountStore.accounts.find((acc) => acc.accountInfo.id === clientId.value)
|
|
return `${acc?.accountInfo.serverInfo.url as string}/workspaces/${
|
|
projectDetails.value?.workspace?.slug
|
|
}`
|
|
})
|
|
|
|
// Subscribe to version created events at a project level, and filter to any receivers (if any)
|
|
const { onResult } = useSubscription(
|
|
versionCreatedSubscription,
|
|
() => ({ projectId: props.project.projectId }),
|
|
() => ({ clientId: clientId.value, enabled: accountExists.value })
|
|
)
|
|
|
|
onResult((res) => {
|
|
if (!res.data) return
|
|
if (res.data?.projectVersionsUpdated?.type !== 'CREATED') return
|
|
|
|
const relevantReceiver = props.project.receivers.find(
|
|
(r) => r.modelId === res.data?.projectVersionsUpdated.version?.model.id
|
|
)
|
|
if (!relevantReceiver) return
|
|
|
|
hostAppStore.patchModel(relevantReceiver.modelCardId, {
|
|
latestVersionId: res.data.projectVersionsUpdated.version?.id,
|
|
latestVersionCreatedAt: res.data.projectVersionsUpdated.version?.createdAt,
|
|
hasDismissedUpdateWarning: false,
|
|
displayReceiveComplete: false
|
|
})
|
|
})
|
|
</script>
|