Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,24 @@ export interface Props {
* @docs Text to be displayed.
*/
text?: string
/**
* @docs Teleport the drop to a different DOM element. This can be useful to prevent overflow issues, e.g. inside tables.
*/
teleport?: string
}

const { title, endText = '', list = [], readMoreLink = '', text = '' } = defineProps<Props>()
const {
title,
endText = '',
list = [],
readMoreLink = '',
text = '',
teleport = ''
} = defineProps<Props>()

const dropId = ref(uniqueId('oc-contextual-helper-'))

const buttonId = computed(() => `${dropId.value}-button`)
const toggleId = computed(() => `#${buttonId.value}`)
const props = computed(() => ({ title, text, list, endText, readMoreLink }))
const props = computed(() => ({ title, text, list, endText, readMoreLink, teleport }))
</script>
8 changes: 8 additions & 0 deletions packages/design-system/src/components/OcIcon/OcIcon.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ describe('OcIcon', () => {
expect(wrapper.emitted('loaded')).toBeTruthy()
expect(wrapper.emitted('loaded')).toHaveLength(1)
})
it('should emit the error event when the svg could not be loaded', async () => {
const { wrapper } = getWrapper()
const inlineSvg = wrapper.findComponent({ name: 'inline-svg' })

await inlineSvg.vm.$emit('error', new Error('not found'))

expect(wrapper.emitted('error')).toHaveLength(1)
})
})

const getWrapper = (props: PartialComponentProps<typeof OcIcon> = {}) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/design-system/src/components/OcIcon/OcIcon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
:style="color !== '' ? { fill: color } : {}"
:class="tailwindSize"
@loaded="emit('loaded')"
@error="emit('error')"
/>
</component>
</template>
Expand Down Expand Up @@ -79,6 +80,10 @@ export interface Emits {
* @docs Emitted when the SVG has been loaded.
*/
(e: 'loaded'): void
/**
* @docs Emitted when the SVG could not be loaded, e.g. because the icon does not exist.
*/
(e: 'error'): void
}

const {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ describe('OcInfoDrop', () => {
expect(attributes['href']).toBe('opencloud.design')
expect(attributes['target']).toBe('_blank')
})
it('should pass the teleport target to the drop', () => {
const wrapper = getWrapperWithProps({ teleport: '#app-runtime-drop' })
expect(wrapper.findComponent(OcDrop).props('teleport')).toBe('#app-runtime-drop')
})
it('should set end-text prop', () => {
const wrapper = getWrapperWithProps({ endText: 'test-my-text' })
expect(wrapper.find('.info-text-end').text()).toBe('test-my-text')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
:drop-id="dropId"
:toggle="toggle"
:mode="mode"
:teleport="teleport"
close-on-click
enforce-drop-on-mobile
:is-menu="false"
Expand Down Expand Up @@ -93,6 +94,10 @@ export interface Props {
* @docs CSS selector for the element to be used as toggle. By default, the preceding element is used.
*/
toggle?: string
/**
* @docs Teleport the info drop to a different DOM element. This can be useful to prevent overflow issues.
*/
teleport?: string
}

const {
Expand All @@ -103,7 +108,8 @@ const {
mode = 'click',
readMoreLink = '',
text = '',
toggle = ''
toggle = '',
teleport = ''
} = defineProps<Props>()

const dropOpen = ref(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,59 +26,80 @@
<template #name="{ item }">
<div class="flex items-center gap-2">
<oc-icon
:name="item.icon || 'store'"
:name="getIcon(item)"
:fill-type="getIconFillType(item)"
size-class="size-5"
:fill-type="item.iconFillType || 'line'"
@error="onIconError(item)"
/>
<oc-filter-highlight :text="item.name" :term="filterTerm" />
</div>
</template>
<template #version="{ item }">
<span v-text="item.version || '—'" />
</template>
<template #minOpenCloud="{ item }">
<span v-if="loadingCompatibility" :class="shimmerClasses" />
<span v-else v-text="item.minOpenCloud || '—'" />
</template>
<template #maxOpenCloud="{ item }">
<span v-if="loadingCompatibility" :class="shimmerClasses" />
<span v-else v-text="item.maxOpenCloud || '—'" />
</template>
<template #status="{ item }">
<oc-tag
v-if="item.loaded"
appearance="filled"
size="small"
class="border-0 !rounded-sm !bg-green-200 !text-green-900"
>
<span v-text="$gettext('Active')" />
</oc-tag>
<oc-tag
v-else
appearance="filled"
size="small"
class="border-0 !rounded-sm !bg-red-200 !text-red-900"
>
<span v-text="$gettext('Failed')" />
</oc-tag>
<span v-if="loadingCompatibility" :class="shimmerClasses" />
<div v-else class="flex items-center gap-1">
<oc-tag
v-if="item.status === 'active'"
appearance="filled"
size="small"
class="border-0 !rounded-sm !bg-green-200 !text-green-900"
>
<span v-text="$gettext('Active')" />
</oc-tag>
<oc-tag
v-else
appearance="filled"
size="small"
class="border-0 !rounded-sm !bg-red-200 !text-red-900"
>
<span v-text="getStatusLabel(item)" />
</oc-tag>
<oc-contextual-helper
v-if="item.status === 'incompatible'"
:title="$gettext('Incompatible OpenCloud version')"
:text="getIncompatibilityText(item)"
teleport="#app-runtime-drop"
/>
</div>
</template>
</oc-table>
</template>

<script setup lang="ts">
import { NoContentMessage, SortField, useSort } from '@opencloud-eu/web-pkg'
import { FillType, SortDir } from '@opencloud-eu/design-system/helpers'
import { computed, unref } from 'vue'
import { IconFillType, NoContentMessage, SortField, useSort } from '@opencloud-eu/web-pkg'
import { SortDir } from '@opencloud-eu/design-system/helpers'
import { computed, ref, unref } from 'vue'
import { useGettext } from 'vue3-gettext'
import { OcFilterHighlight } from '@opencloud-eu/design-system/components'
import { ExtensionInfo, ExtensionStatus } from './types'

interface ExtensionInfo {
name: string
icon?: string
iconFillType?: FillType
version?: string
loaded: boolean
}

const { extensions, filterTerm = '' } = defineProps<{
const {
extensions,
filterTerm = '',
serverVersion = '',
loadingCompatibility = false
} = defineProps<{
extensions: ExtensionInfo[]
filterTerm?: string
serverVersion?: string
loadingCompatibility?: boolean
}>()

const { $gettext } = useGettext()

const shimmerClasses =
'compatibility-loading shimmer relative inline-block h-3 w-12 overflow-hidden rounded-sm bg-role-shadow opacity-10 align-middle after:absolute after:inset-0 after:transform-[translateX(-100%)] after:animate-shimmer'

const filteredExtensions = computed(() => {
const term = unref(filterTerm).toLowerCase()
if (!term) {
Expand All @@ -91,13 +112,76 @@ const filteredExtensions = computed(() => {
})
})

const fallbackIcon = 'puzzle'
const brokenIcons = ref<string[]>([])

function getIcon({ icon }: ExtensionInfo) {
if (!icon || unref(brokenIcons).includes(icon)) {
return fallbackIcon
}
return icon
}

function getIconFillType(item: ExtensionInfo): IconFillType {
if (getIcon(item) === fallbackIcon) {
return 'line'
}
return item.iconFillType || 'line'
}

function onIconError(item: ExtensionInfo) {
const icon = getIcon(item)
if (icon !== fallbackIcon) {
brokenIcons.value.push(icon)
}
}

function getIncompatibilityText({ minOpenCloud, maxOpenCloud, loaded }: ExtensionInfo) {
let requirement: string
if (minOpenCloud && maxOpenCloud) {
requirement = $gettext(
'This app version requires an OpenCloud version between %{min} and %{max}.',
{ min: minOpenCloud, max: maxOpenCloud }
)
} else if (minOpenCloud) {
requirement = $gettext('This app version requires OpenCloud %{min} or higher.', {
min: minOpenCloud
})
} else {
requirement = $gettext('This app version requires OpenCloud %{max} or lower.', {
max: maxOpenCloud
})
}

const sentences = [requirement]
if (serverVersion) {
sentences.push(
$gettext('The current OpenCloud version is %{version}.', { version: serverVersion })
)
}
if (!loaded) {
sentences.push($gettext('The app could not be loaded, most likely because of this.'))
}
return sentences.join(' ')
}

const statusOrder: ExtensionStatus[] = ['active', 'incompatible', 'failed']

function getStatusLabel({ status }: ExtensionInfo) {
const labels: Record<ExtensionStatus, string> = {
active: $gettext('Active'),
incompatible: $gettext('Incompatible'),
failed: $gettext('Failed')
}
return labels[status]
}

const sortFields: SortField[] = [
{ name: 'name', sortable: true, sortDir: SortDir.Asc },
{
name: 'status',
prop: 'loaded',
// active extensions come first in ascending order
sortable: (loaded: boolean) => (loaded ? 0 : 1),
sortable: (status: ExtensionStatus) => statusOrder.indexOf(status),
sortDir: SortDir.Asc
}
]
Expand All @@ -119,8 +203,21 @@ const fields = computed(() => [
{
name: 'version',
title: $gettext('Version'),
type: 'slot'
},
{
name: 'minOpenCloud',
title: $gettext('Min. OpenCloud'),
type: 'slot',
width: 'shrink' as const
thClass: 'hidden md:table-cell',
tdClass: 'hidden md:table-cell'
},
{
name: 'maxOpenCloud',
title: $gettext('Max. OpenCloud'),
type: 'slot',
thClass: 'hidden md:table-cell',
tdClass: 'hidden md:table-cell'
},
{
name: 'status',
Expand All @@ -131,3 +228,11 @@ const fields = computed(() => [
}
])
</script>

<style scoped>
@layer components {
.shimmer::after {
background-image: linear-gradient(90deg, #ffffff00 0, #ffffff33 20%, #ffffff80 60%, #ffffff00);
}
}
</style>
14 changes: 14 additions & 0 deletions packages/web-app-admin-settings/src/components/Extensions/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { IconFillType } from '@opencloud-eu/web-pkg'

export type ExtensionStatus = 'active' | 'incompatible' | 'failed'

export interface ExtensionInfo {
name: string
icon?: string
iconFillType?: IconFillType
version?: string
minOpenCloud?: string
maxOpenCloud?: string
status: ExtensionStatus
loaded: boolean
}
1 change: 1 addition & 0 deletions packages/web-app-admin-settings/src/composables/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './actions'
export * from './stores'
export * from './users'
export * from './useAppCompatibility'
Loading
Loading