Maintenance: Show the next scheduled visibility as a badge in the answer header.
Approved-by: Benjamin Scharf <bs@zammad.com> Co-authored-by: Ben <bs@zammad.com>
This commit is contained in:
@@ -8,6 +8,12 @@ import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
|
||||
// state that is missing an entry.
|
||||
// Should be kept in sync with `knowledgeBaseVisibilityMeta` in useKnowledgeBaseVisibility.ts,
|
||||
// which is used to compute the category icon and color for a given state.
|
||||
//
|
||||
// `timestampLabel` is how a reached date of that state is named where it is shown as a value of its
|
||||
// own - "Internally published" rather than the badge's "Internal". Required of every state but
|
||||
// `draft`, which is the absence of all three dates and therefore has no date to label: hence the
|
||||
// intersection below rather than one optional field, which would have let a missing label through
|
||||
// for the three states that do need one.
|
||||
export const visibilityMeta = {
|
||||
[EnumKnowledgeBaseVisibility.Draft]: {
|
||||
icon: 'kb-draft',
|
||||
@@ -17,19 +23,26 @@ export const visibilityMeta = {
|
||||
[EnumKnowledgeBaseVisibility.Internal]: {
|
||||
icon: 'kb-internal',
|
||||
label: __('Internal'),
|
||||
timestampLabel: __('Internally published'),
|
||||
class: 'text-blue-800!',
|
||||
},
|
||||
[EnumKnowledgeBaseVisibility.Published]: {
|
||||
icon: 'kb-published',
|
||||
label: __('Published'),
|
||||
timestampLabel: __('Published'),
|
||||
class: 'text-green-400!',
|
||||
},
|
||||
[EnumKnowledgeBaseVisibility.Archived]: {
|
||||
icon: 'kb-archived',
|
||||
label: __('Archived'),
|
||||
class: 'text-neutral-400! dark:text-neutral-600!',
|
||||
timestampLabel: __('Archived'),
|
||||
class: 'text-gray-100! dark:text-neutral-400!',
|
||||
},
|
||||
} as const satisfies Record<
|
||||
EnumKnowledgeBaseVisibility,
|
||||
{ icon: string; label: string; class: string }
|
||||
>
|
||||
> &
|
||||
Record<
|
||||
Exclude<EnumKnowledgeBaseVisibility, EnumKnowledgeBaseVisibility.Draft>,
|
||||
{ timestampLabel: string }
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import { useReactiveNow } from '#shared/composables/useReactiveNow.ts'
|
||||
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
|
||||
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
@@ -41,5 +42,32 @@ export const useKnowledgeBaseAnswerReachedDates = (
|
||||
}
|
||||
})
|
||||
|
||||
return { reachedDates }
|
||||
// The state the answer is actually in right now: the last state of the machine
|
||||
// (internalAt < publishedAt < archivedAt) whose date has been reached.
|
||||
const reachedDate = computed(() => {
|
||||
const { internalAt, publishedAt, archivedAt } = reachedDates.value
|
||||
|
||||
return archivedAt ?? publishedAt ?? internalAt
|
||||
})
|
||||
|
||||
// The same rung named as a state, which is what the server's `visibility` field says - and only
|
||||
// says as of the request that answered it: it is derived once, on the server's clock, so a
|
||||
// schedule falling due while the page is open leaves it naming a state the answer has left.
|
||||
// Read off the same reached dates here, it moves with `reachedDate` instead, and a badge can no
|
||||
// longer disagree with the date it is stamped with.
|
||||
//
|
||||
// Mirrors CanBePublished::StateMachine#calculated_state, save for its `draft`: a caller that has
|
||||
// no dates at all is not necessarily looking at a draft - the create header has nothing but the
|
||||
// visibility being picked in the form - so what "none reached" means is left to the caller.
|
||||
const reachedVisibility = computed(() => {
|
||||
const { internalAt, publishedAt, archivedAt } = reachedDates.value
|
||||
|
||||
if (archivedAt) return EnumKnowledgeBaseVisibility.Archived
|
||||
if (publishedAt) return EnumKnowledgeBaseVisibility.Published
|
||||
if (internalAt) return EnumKnowledgeBaseVisibility.Internal
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
return { reachedDates, reachedDate, reachedVisibility }
|
||||
}
|
||||
@@ -3,24 +3,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import CommonDateTime from '#shared/components/CommonDateTime/CommonDateTime.vue'
|
||||
import {
|
||||
NotificationTypes,
|
||||
useNotifications,
|
||||
} from '#shared/components/CommonNotifications/index.ts'
|
||||
import { useConfirmation } from '#shared/composables/useConfirmation.ts'
|
||||
import { useTouchDevice } from '#shared/composables/useTouchDevice.ts'
|
||||
import { EnumKnowledgeBaseSchedulableVisibility } from '#shared/graphql/types.ts'
|
||||
import type { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
|
||||
import type { EnumKnowledgeBaseSchedulableVisibility } from '#shared/graphql/types.ts'
|
||||
import { i18n } from '#shared/i18n.ts'
|
||||
import { MutationHandler } from '#shared/server/apollo/handler/index.ts'
|
||||
|
||||
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
|
||||
import { useFlyout } from '#desktop/components/CommonFlyout/useFlyout.ts'
|
||||
import CommonSectionCollapse from '#desktop/components/CommonSectionCollapse/CommonSectionCollapse.vue'
|
||||
import { visibilityMeta } from '#desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
|
||||
import { useKnowledgeBaseAnswerVisibilityScheduleRemoveMutation } from '#desktop/entities/knowledge-base/graphql/mutations/knowledgeBaseAnswerVisibilityScheduleRemove.api.ts'
|
||||
|
||||
import KnowledgeBaseAnswerScheduledVisibilityItem from '../../KnowledgeBaseAnswerScheduledVisibilityItem/KnowledgeBaseAnswerScheduledVisibilityItem.vue'
|
||||
import { metaFor } from '../../KnowledgeBaseAnswerScheduledVisibilityItem/visibilityScheduleMeta.ts'
|
||||
|
||||
import type { KnowledgeBaseAnswerHeader } from '../../../types.ts'
|
||||
|
||||
interface Props {
|
||||
@@ -44,22 +44,6 @@ const { notify } = useNotifications()
|
||||
const { isTouchDevice } = useTouchDevice()
|
||||
const { waitForConfirmation } = useConfirmation()
|
||||
|
||||
// The two enums name the same states, and their generated values are identical: the schedulable one
|
||||
// is the answer's own visibility without `draft`, which stores no date and can therefore not be
|
||||
// scheduled. TypeScript keeps them apart all the same, so the lookup into the shared
|
||||
// icon/colour/label map is bridged here rather than at every use.
|
||||
const metaFor = (visibility: EnumKnowledgeBaseSchedulableVisibility) =>
|
||||
visibilityMeta[visibility as unknown as EnumKnowledgeBaseVisibility]
|
||||
|
||||
// `archived`'s own colour is a dim neutral, which is what makes it read as *inactive* in the answer
|
||||
// list - and as too faint to read here, where it is carrying a whole line of text rather than
|
||||
// tinting a small icon. So it keeps the default label colour, and only the states with a real hue
|
||||
// are tinted (`internal` blue, `published` green).
|
||||
const colorFor = (visibility: EnumKnowledgeBaseSchedulableVisibility) =>
|
||||
visibility === EnumKnowledgeBaseSchedulableVisibility.Archived
|
||||
? undefined
|
||||
: metaFor(visibility).class
|
||||
|
||||
const removeMutation = new MutationHandler(
|
||||
useKnowledgeBaseAnswerVisibilityScheduleRemoveMutation({}),
|
||||
{ errorNotificationMessage: __('The scheduled visibility change could not be removed.') },
|
||||
@@ -123,28 +107,15 @@ const removeSchedule = async (visibility: EnumKnowledgeBaseSchedulableVisibility
|
||||
:aria-labelledby="headerId"
|
||||
class="flex w-full flex-col rounded-lg bg-blue-200 px-2.5 dark:bg-gray-700"
|
||||
>
|
||||
<li
|
||||
<!-- The row itself is shared with the read-only popover on the answer header's badge
|
||||
(KnowledgeBaseAnswerScheduledVisibilityItem); what stays here is everything that
|
||||
edits. -->
|
||||
<KnowledgeBaseAnswerScheduledVisibilityItem
|
||||
v-for="schedule in schedules"
|
||||
:key="schedule.visibility"
|
||||
class="group flex items-center gap-1.5 py-2.5"
|
||||
:visibility="schedule.visibility"
|
||||
:scheduled-at="schedule.scheduledAt"
|
||||
>
|
||||
<!-- One clock for every state, rather than the state icons the answer list and the
|
||||
taskbar tab show - and tinted with the state's colour where it has one, which is
|
||||
what the design has. See #colorFor for why `archived` does not get its own. -->
|
||||
<CommonLabel
|
||||
class="grow"
|
||||
prefix-icon="clock"
|
||||
:icon-color="colorFor(schedule.visibility)"
|
||||
>
|
||||
<span :class="colorFor(schedule.visibility)">
|
||||
{{ $t(metaFor(schedule.visibility).label) }}
|
||||
</span>
|
||||
|
||||
<!-- Relative, like every other date of this sidebar (the publication dates above),
|
||||
and what the design shows: "in 2 days". -->
|
||||
<CommonDateTime :date-time="schedule.scheduledAt" type="relative" />
|
||||
</CommonLabel>
|
||||
|
||||
<!-- Hover-only on a pointer device, always there on a touch one, which has no hover to
|
||||
reveal it with. -->
|
||||
<CommonButton
|
||||
@@ -157,7 +128,7 @@ const removeSchedule = async (visibility: EnumKnowledgeBaseSchedulableVisibility
|
||||
variant="remove"
|
||||
@click.stop="removeSchedule(schedule.visibility)"
|
||||
/>
|
||||
</li>
|
||||
</KnowledgeBaseAnswerScheduledVisibilityItem>
|
||||
</ul>
|
||||
|
||||
<CommonLabel v-else size="small">
|
||||
|
||||
@@ -5,12 +5,12 @@ import { computed } from 'vue'
|
||||
|
||||
import CommonDateTime from '#shared/components/CommonDateTime/CommonDateTime.vue'
|
||||
import { userDisplayName } from '#shared/entities/user/utils/getUserDisplayName.ts'
|
||||
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
|
||||
|
||||
import CommonObjectAttribute from '#desktop/components/CommonObjectAttribute/CommonObjectAttribute.vue'
|
||||
import CommonObjectAttributeContainer from '#desktop/components/CommonObjectAttribute/CommonObjectAttributeContainer.vue'
|
||||
import { visibilityMeta } from '#desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
|
||||
|
||||
import { useKnowledgeBaseAnswerReachedDates } from '../../../composables/useKnowledgeBaseAnswerReachedDates.ts'
|
||||
import { useKnowledgeBaseAnswerReachedDates } from '#desktop/entities/knowledge-base/composables/useKnowledgeBaseAnswerReachedDates.ts'
|
||||
|
||||
import KnowledgeBaseAnswerLinkedTickets from './KnowledgeBaseAnswerLinkedTickets.vue'
|
||||
import KnowledgeBaseAnswerScheduledVisibility from './KnowledgeBaseAnswerScheduledVisibility.vue'
|
||||
@@ -38,15 +38,24 @@ const editedByLabel = computed(() => {
|
||||
{{ $t(visibilityMeta[answer.visibility].label) }}
|
||||
</CommonObjectAttribute>
|
||||
|
||||
<CommonObjectAttribute v-if="reachedDates.internalAt" :label="__('Internally published')">
|
||||
<CommonObjectAttribute
|
||||
v-if="reachedDates.internalAt"
|
||||
:label="visibilityMeta[EnumKnowledgeBaseVisibility.Internal].timestampLabel"
|
||||
>
|
||||
<CommonDateTime :date-time="reachedDates.internalAt" type="relative" />
|
||||
</CommonObjectAttribute>
|
||||
|
||||
<CommonObjectAttribute v-if="reachedDates.publishedAt" :label="__('Published')">
|
||||
<CommonObjectAttribute
|
||||
v-if="reachedDates.publishedAt"
|
||||
:label="visibilityMeta[EnumKnowledgeBaseVisibility.Published].timestampLabel"
|
||||
>
|
||||
<CommonDateTime :date-time="reachedDates.publishedAt" type="relative" />
|
||||
</CommonObjectAttribute>
|
||||
|
||||
<CommonObjectAttribute v-if="reachedDates.archivedAt" :label="__('Archived')">
|
||||
<CommonObjectAttribute
|
||||
v-if="reachedDates.archivedAt"
|
||||
:label="visibilityMeta[EnumKnowledgeBaseVisibility.Archived].timestampLabel"
|
||||
>
|
||||
<CommonDateTime :date-time="reachedDates.archivedAt" type="relative" />
|
||||
</CommonObjectAttribute>
|
||||
|
||||
|
||||
@@ -37,32 +37,38 @@ const { form } = useForm()
|
||||
|
||||
const formSchema = [
|
||||
{
|
||||
// The same states the answer's own visibility field offers, minus `draft`: a state is stored as
|
||||
// the date it is reached at, and `draft` is what no date at all means - there is nothing to
|
||||
// put in the future for it. Which is also what the mutation's enum says.
|
||||
name: 'visibility',
|
||||
label: __('Visibility'),
|
||||
type: 'radioList',
|
||||
required: true,
|
||||
props: {
|
||||
options: answerSchedulableVisibilityOptions,
|
||||
},
|
||||
},
|
||||
{
|
||||
// A date that is not ahead is not a schedule: it would change the state at once, which is the
|
||||
// edit form's business. The service refuses one either way, so this is about where the
|
||||
// complaint lands - `futureOnly` only limits what the *picker* offers, and a date typed into
|
||||
// the field's text input sails past it, so the rule is what actually holds. It puts the
|
||||
// message on the field rather than at the top of the flyout, and saves the round trip.
|
||||
name: 'scheduledAt',
|
||||
label: __('Schedule for'),
|
||||
type: 'datetime',
|
||||
required: true,
|
||||
validation: 'date_after',
|
||||
props: {
|
||||
futureOnly: true,
|
||||
clearable: false,
|
||||
},
|
||||
isLayout: true,
|
||||
component: 'FormGroup',
|
||||
children: [
|
||||
{
|
||||
// The same states the answer's own visibility field offers, minus `draft`: a state is stored as
|
||||
// the date it is reached at, and `draft` is what no date at all means - there is nothing to
|
||||
// put in the future for it. Which is also what the mutation's enum says.
|
||||
name: 'visibility',
|
||||
label: __('Visibility'),
|
||||
type: 'radioList',
|
||||
required: true,
|
||||
props: {
|
||||
options: answerSchedulableVisibilityOptions,
|
||||
},
|
||||
},
|
||||
{
|
||||
// A date that is not ahead is not a schedule: it would change the state at once, which is the
|
||||
// edit form's business. The service refuses one either way, so this is about where the
|
||||
// complaint lands - `futureOnly` only limits what the *picker* offers, and a date typed into
|
||||
// the field's text input sails past it, so the rule is what actually holds. It puts the
|
||||
// message on the field rather than at the top of the flyout, and saves the round trip.
|
||||
name: 'scheduledAt',
|
||||
label: __('Schedule for'),
|
||||
type: 'datetime',
|
||||
required: true,
|
||||
validation: 'date_after',
|
||||
props: {
|
||||
futureOnly: true,
|
||||
clearable: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -94,11 +94,6 @@ const publishedInAWeek = {
|
||||
scheduledAt: inDays(8),
|
||||
}
|
||||
|
||||
const archivedInAMonth = {
|
||||
visibility: EnumKnowledgeBaseSchedulableVisibility.Archived,
|
||||
scheduledAt: inDays(31),
|
||||
}
|
||||
|
||||
describe('KnowledgeBaseAnswerScheduledVisibility', () => {
|
||||
it('lists every change the answer is going to make', () => {
|
||||
const view = renderSection([internalInTwoDays, publishedInAWeek])
|
||||
@@ -135,15 +130,6 @@ describe('KnowledgeBaseAnswerScheduledVisibility', () => {
|
||||
expect(view.getByText('Published')).toHaveClass('text-green-400!')
|
||||
})
|
||||
|
||||
// `archived`'s own colour is a dim neutral - it says "inactive" on a small icon in the answer
|
||||
// list, and is too faint for a line of text here. So this one keeps the default label colour.
|
||||
it('leaves an archival in the default text colour', () => {
|
||||
const view = renderSection([archivedInAMonth])
|
||||
|
||||
// Nothing at all rather than a different tint: the label inherits CommonLabel's own colour.
|
||||
expect(view.getByText('Archived').className).toBe('')
|
||||
})
|
||||
|
||||
it('states that nothing is scheduled', () => {
|
||||
const view = renderSection([])
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import CommonDateTime from '#shared/components/CommonDateTime/CommonDateTime.vue'
|
||||
import type { EnumKnowledgeBaseSchedulableVisibility } from '#shared/graphql/types.ts'
|
||||
|
||||
import { colorFor, metaFor } from './visibilityScheduleMeta.ts'
|
||||
|
||||
// One scheduled visibility change, as both surfaces that list them render it: the editor's sidebar
|
||||
// section, where a row can also be removed, and the read-only popover on the answer header's
|
||||
// badge. Shared so the state colours - and the exception `archived` makes among them - are
|
||||
// decided once, in `visibilityScheduleMeta`.
|
||||
interface Props {
|
||||
visibility: EnumKnowledgeBaseSchedulableVisibility
|
||||
scheduledAt: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="group flex items-center gap-1.5 py-2.5">
|
||||
<!-- One clock for every state, rather than the state icons the answer list and the taskbar tab
|
||||
show - and tinted with the state's colour where it has one, which is what the design has.
|
||||
See #colorFor for why `archived` does not get its own. -->
|
||||
<CommonLabel class="grow" prefix-icon="clock" :icon-color="colorFor(visibility)">
|
||||
<span :class="colorFor(visibility)">
|
||||
{{ $t(metaFor(visibility).label) }}
|
||||
</span>
|
||||
|
||||
<!-- Relative, like every other date beside it, and what the design shows: "in 2 days". -->
|
||||
<CommonDateTime :date-time="scheduledAt" type="relative" />
|
||||
</CommonLabel>
|
||||
|
||||
<!-- Whatever the surface puts after the row: the sidebar's remove button, nothing at all in the
|
||||
read-only popover. Inside the `li` and its `group`, which is what that button's
|
||||
hover-to-reveal keys off. -->
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import { renderComponent } from '#tests/support/components/index.ts'
|
||||
|
||||
import { EnumKnowledgeBaseSchedulableVisibility } from '#shared/graphql/types.ts'
|
||||
|
||||
import KnowledgeBaseAnswerScheduledVisibilityItem from '../KnowledgeBaseAnswerScheduledVisibilityItem.vue'
|
||||
|
||||
const hour = 60 * 60 * 1000
|
||||
|
||||
// The extra hour keeps the date clear of the boundary the relative formatter floors at - exactly two
|
||||
// days from now comes out as "in 1 day" by the time the assertion runs.
|
||||
const inDays = (days: number) => new Date(Date.now() + days * 24 * hour + hour).toISOString()
|
||||
|
||||
const renderItem = (
|
||||
visibility: EnumKnowledgeBaseSchedulableVisibility,
|
||||
slots?: Record<string, string>,
|
||||
) =>
|
||||
renderComponent(KnowledgeBaseAnswerScheduledVisibilityItem, {
|
||||
props: { visibility, scheduledAt: inDays(2) },
|
||||
slots,
|
||||
store: true,
|
||||
router: true,
|
||||
})
|
||||
|
||||
describe('KnowledgeBaseAnswerScheduledVisibilityItem', () => {
|
||||
it('names the state and when it happens', () => {
|
||||
const view = renderItem(EnumKnowledgeBaseSchedulableVisibility.Internal)
|
||||
|
||||
expect(view.getByText('Internal')).toBeInTheDocument()
|
||||
expect(view.getByText('in 2 days')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// A list row wherever it is used, so both surfaces get the list semantics without repeating them.
|
||||
it('renders as a list item', () => {
|
||||
const view = renderItem(EnumKnowledgeBaseSchedulableVisibility.Internal)
|
||||
|
||||
// By tag rather than by role: an `li` only carries the `listitem` role inside a list, and the
|
||||
// list is the caller's - the sidebar section and the header popover each bring their own.
|
||||
expect(view.container.querySelector('li')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// One clock for every state, rather than the state icons the answer list shows.
|
||||
it.each([
|
||||
EnumKnowledgeBaseSchedulableVisibility.Internal,
|
||||
EnumKnowledgeBaseSchedulableVisibility.Published,
|
||||
EnumKnowledgeBaseSchedulableVisibility.Archived,
|
||||
])('carries a clock for a scheduled %s', (visibility) => {
|
||||
const view = renderItem(visibility)
|
||||
|
||||
expect(view.container.querySelector('.icon-clock')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The state's own colour is what makes the rows scannable, as it does in the answer list.
|
||||
it.each([
|
||||
[EnumKnowledgeBaseSchedulableVisibility.Internal, 'Internal', 'text-blue-800!'],
|
||||
[EnumKnowledgeBaseSchedulableVisibility.Published, 'Published', 'text-green-400!'],
|
||||
])('tints a scheduled %s', (visibility, label, tint) => {
|
||||
const view = renderItem(visibility)
|
||||
|
||||
expect(view.getByText(label)).toHaveClass(tint)
|
||||
})
|
||||
|
||||
// What the sidebar puts its remove button into, and the read-only popover leaves empty.
|
||||
it('renders what the caller appends to the row', () => {
|
||||
const view = renderItem(EnumKnowledgeBaseSchedulableVisibility.Internal, {
|
||||
default: '<button type="button">Remove</button>',
|
||||
})
|
||||
|
||||
expect(view.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('appends nothing of its own', () => {
|
||||
const view = renderItem(EnumKnowledgeBaseSchedulableVisibility.Internal)
|
||||
|
||||
expect(view.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import { EnumKnowledgeBaseSchedulableVisibility } from '#shared/graphql/types.ts'
|
||||
|
||||
import { visibilityMeta } from '#desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
|
||||
|
||||
// The two enums name the same states, and their generated values are identical: the schedulable one
|
||||
// is the answer's own visibility without `draft`, which stores no date and can therefore not be
|
||||
// scheduled. TypeScript keeps them apart all the same, so the lookup into the shared
|
||||
// icon/colour/label map is bridged here rather than at every use.
|
||||
export const metaFor = (visibility: EnumKnowledgeBaseSchedulableVisibility) =>
|
||||
visibilityMeta[visibility]
|
||||
|
||||
export const colorFor = (visibility: EnumKnowledgeBaseSchedulableVisibility) =>
|
||||
metaFor(visibility).class
|
||||
@@ -10,10 +10,14 @@ import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
|
||||
import { i18n } from '#shared/i18n.ts'
|
||||
import { useSessionStore } from '#shared/stores/session.ts'
|
||||
|
||||
import CommonPopoverWithTrigger from '#desktop/components/CommonPopover/CommonPopoverWithTrigger.vue'
|
||||
import KnowledgeBaseAnswerIcon from '#desktop/components/KnowledgeBaseAnswerIcon/KnowledgeBaseAnswerIcon.vue'
|
||||
import { visibilityMeta } from '#desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
|
||||
import { useKnowledgeBaseAnswerReachedDates } from '#desktop/entities/knowledge-base/composables/useKnowledgeBaseAnswerReachedDates.ts'
|
||||
|
||||
import { useKnowledgeBaseAnswerReachedDates } from '../../composables/useKnowledgeBaseAnswerReachedDates.ts'
|
||||
import { useKnowledgeBaseAnswerNextVisibilitySchedule } from '../../composables/useKnowledgeBaseAnswerNextVisibilitySchedule.ts'
|
||||
|
||||
import KnowledgeBaseAnswerScheduledVisibilityPopover from './KnowledgeBaseAnswerScheduledVisibilityPopover.vue'
|
||||
|
||||
import type { KnowledgeBaseAnswerHeaderDetailsAnswer } from './types.ts'
|
||||
|
||||
@@ -39,12 +43,64 @@ const badgeVariants: Record<EnumKnowledgeBaseVisibility, BadgeVariant> = {
|
||||
[EnumKnowledgeBaseVisibility.Archived]: 'tertiary',
|
||||
}
|
||||
|
||||
const visibility = computed(() => props.answer.visibility)
|
||||
// The state the badge names and the date it was reached at, both off the *reached* dates rather
|
||||
// than the raw fields: a publication scheduled for next week can neither date nor rename a badge
|
||||
// that still says "DRAFT". What is still ahead belongs to the scheduled badge below, and to
|
||||
// nobody who may not edit the answer.
|
||||
const { reachedDate, reachedVisibility } = useKnowledgeBaseAnswerReachedDates(() => props.answer)
|
||||
|
||||
// Only the dates the answer has actually reached: this strip says what the answer is, not what it is
|
||||
// going to become - without the filter a draft whose publication is scheduled for next week would
|
||||
// claim "PUBLISHED IN 1 WEEK" here, in the reader's header as much as in the editor's.
|
||||
const { reachedDates } = useKnowledgeBaseAnswerReachedDates(() => props.answer)
|
||||
// The clock rather than `answer.visibility`, which the server derives once per request and which
|
||||
// therefore keeps naming the state the answer was in when the page loaded - so a schedule falling
|
||||
// due while it stays open would leave a green "PUBLISHED" badge dated with the archival it has
|
||||
// just reached. Both halves move together now.
|
||||
//
|
||||
// Falling back to the field for an answer that has reached no date at all, which is the one case
|
||||
// the dates cannot name: that is a draft for a stored answer, but the create header has no stored
|
||||
// answer at all and passes the visibility being picked in the form (see its `visibility` prop).
|
||||
const visibility = computed(() => reachedVisibility.value ?? props.answer.visibility)
|
||||
|
||||
// The bare timestamp, the way the edit chip at the foot of the strip and `CommonDateTime` itself
|
||||
// render theirs: the badge already names the state in its own text, so repeating the state here
|
||||
// would say it twice.
|
||||
const reachedTooltip = computed(() =>
|
||||
reachedDate.value ? i18n.dateTime(reachedDate.value) : undefined,
|
||||
)
|
||||
|
||||
// And the other half of the same clock: the next state the answer is going to reach. Absent for
|
||||
// anybody who may not edit it, which is the whole of the permission check here - see the
|
||||
// composable for why this component has none of its own.
|
||||
const { nextVisibilitySchedule, pendingVisibilitySchedules } =
|
||||
useKnowledgeBaseAnswerNextVisibilitySchedule(() => props.answer)
|
||||
|
||||
// Bridged once here rather than at both lookups below: the two enums name the same states
|
||||
// with identical values - the schedulable one is the answer's visibility without `draft`, which
|
||||
// stores no date and can therefore not be scheduled - and TypeScript keeps them apart all the
|
||||
// same.
|
||||
const nextSchedule = computed(() => {
|
||||
const schedule = nextVisibilitySchedule.value
|
||||
|
||||
if (!schedule) return undefined
|
||||
|
||||
return {
|
||||
scheduledAt: schedule.scheduledAt,
|
||||
visibility: schedule.visibility,
|
||||
}
|
||||
})
|
||||
|
||||
// What the badge says, for whoever cannot see it. `role="button"` makes the trigger's children
|
||||
// presentational, so the badge's own text is dropped from the accessibility tree rather than just
|
||||
// outranked - without this the announced name would be the popover's title alone, and the state
|
||||
// and its timing would only be reachable by opening the popover.
|
||||
const nextScheduleLabel = computed(() => {
|
||||
const schedule = nextSchedule.value
|
||||
|
||||
if (!schedule) return undefined
|
||||
|
||||
return i18n.t(
|
||||
'Scheduled visibility: %s',
|
||||
`${i18n.t(visibilityMeta[schedule.visibility].label)} ${i18n.relativeDateTime(schedule.scheduledAt)}`,
|
||||
)
|
||||
})
|
||||
|
||||
// Deliberately a fixed value: the chip is rendered once per answer load, so it
|
||||
// does not track elapsed time the way the CommonDateTime badges above do.
|
||||
@@ -73,7 +129,15 @@ const editedTooltip = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex max-w-full flex-wrap items-center gap-2.5 text-nowrap *:h-7">
|
||||
<CommonBadge :variant="badgeVariants[visibility]" class="gap-1 uppercase">
|
||||
<!-- `.supportive`, so the date lands in `aria-description` beside the badge's own text rather
|
||||
than replacing it as an `aria-label`: the badge visibly reads "INTERNAL" where the
|
||||
timestamp label is "Internally published", and a plain tooltip would make the two
|
||||
disagree. Same choice as the edit chip at the foot of the strip. -->
|
||||
<CommonBadge
|
||||
v-tooltip.supportive="reachedTooltip"
|
||||
:variant="badgeVariants[visibility]"
|
||||
class="gap-1 uppercase"
|
||||
>
|
||||
<KnowledgeBaseAnswerIcon decorative :visibility="visibility" size="tiny" />
|
||||
{{ $t(visibilityMeta[visibility].label) }}
|
||||
</CommonBadge>
|
||||
@@ -90,29 +154,39 @@ const editedTooltip = computed(() => {
|
||||
<CommonIcon name="translate" size="xs" decorative />
|
||||
</CommonBadge>
|
||||
|
||||
<CommonBadge v-if="reachedDates.internalAt" variant="tertiary" class="uppercase">
|
||||
<CommonDateTime :date-time="reachedDates.internalAt" type="relative" class="ms-1">
|
||||
<template #prefix>
|
||||
{{ $t('Internally published') }}
|
||||
</template>
|
||||
</CommonDateTime>
|
||||
</CommonBadge>
|
||||
<CommonPopoverWithTrigger
|
||||
v-if="nextSchedule"
|
||||
class="flex rounded-md outline-offset-1 focus-visible:outline-2"
|
||||
placement="arrowStart"
|
||||
orientation="bottom"
|
||||
trigger-link-active-class="outline-blue-800! outline-2!"
|
||||
:aria-label="nextScheduleLabel"
|
||||
>
|
||||
<!-- The clock-filtered list rather than the field as it arrived, so the rows and the badge
|
||||
in front of them agree about what has already happened. -->
|
||||
<template #popover-content="{ popoverId }">
|
||||
<KnowledgeBaseAnswerScheduledVisibilityPopover
|
||||
:id="popoverId"
|
||||
:schedules="pendingVisibilitySchedules"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<CommonBadge v-if="reachedDates.publishedAt" variant="tertiary" class="uppercase">
|
||||
<CommonDateTime :date-time="reachedDates.publishedAt" type="relative" class="ms-1">
|
||||
<template #prefix>
|
||||
{{ $t('Published') }}
|
||||
</template>
|
||||
</CommonDateTime>
|
||||
</CommonBadge>
|
||||
|
||||
<CommonBadge v-if="reachedDates.archivedAt" variant="tertiary" class="uppercase">
|
||||
<CommonDateTime :date-time="reachedDates.archivedAt" type="relative" class="ms-1">
|
||||
<template #prefix>
|
||||
{{ $t('Archived') }}
|
||||
</template>
|
||||
</CommonDateTime>
|
||||
</CommonBadge>
|
||||
<!-- The forward-looking visibility badge: the next date the answer is going to reach,
|
||||
which is why it carries the target state's own color where they share a neutral grey
|
||||
- the same `badgeVariants` the visibility badge resolves, so a scheduled state is tinted
|
||||
here exactly as it will be once it arrives. -->
|
||||
<CommonBadge
|
||||
class="cursor-pointer gap-1 uppercase"
|
||||
:variant="badgeVariants[nextSchedule.visibility]"
|
||||
>
|
||||
<CommonIcon name="eye" size="tiny" decorative />
|
||||
<CommonDateTime :date-time="nextSchedule.scheduledAt" type="relative">
|
||||
<template #prefix>
|
||||
{{ $t(visibilityMeta[nextSchedule.visibility].label) }}
|
||||
</template>
|
||||
</CommonDateTime>
|
||||
</CommonBadge>
|
||||
</CommonPopoverWithTrigger>
|
||||
|
||||
<CommonBadge
|
||||
v-if="editedLabel"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EnumKnowledgeBaseSchedulableVisibility } from '#shared/graphql/types.ts'
|
||||
import getUuid from '#shared/utils/getUuid.ts'
|
||||
|
||||
import KnowledgeBaseAnswerScheduledVisibilityItem from '../KnowledgeBaseAnswerScheduledVisibilityItem/KnowledgeBaseAnswerScheduledVisibilityItem.vue'
|
||||
|
||||
// Every change the answer is going to make, where the badge that opens this shows only the next of
|
||||
// them. Read-only: scheduling and removing stay with the editor's sidebar section, which is the
|
||||
// other user of the row component below.
|
||||
interface Props {
|
||||
id?: string
|
||||
schedules: {
|
||||
visibility: EnumKnowledgeBaseSchedulableVisibility
|
||||
scheduledAt: string
|
||||
}[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
// The list names its own heading rather than the badge that opens the popover: what the reader needs
|
||||
// is what this list *is*, and the badge's label only repeats its first row.
|
||||
const headingId = `knowledge-base-scheduled-visibility-${getUuid()}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :id="id" data-type="popover" class="flex flex-col gap-3 p-3">
|
||||
<CommonLabel :id="headingId" tag="h3" size="small" class="text-stone-200 dark:text-neutral-500">
|
||||
{{ $t('Scheduled visibility') }}
|
||||
</CommonLabel>
|
||||
|
||||
<ul class="flex flex-col gap-5" :aria-labelledby="headingId">
|
||||
<KnowledgeBaseAnswerScheduledVisibilityItem
|
||||
v-for="schedule in schedules"
|
||||
:key="schedule.visibility"
|
||||
class="py-0!"
|
||||
:visibility="schedule.visibility"
|
||||
:scheduled-at="schedule.scheduledAt"
|
||||
/>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,10 +1,20 @@
|
||||
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import { waitFor, within } from '@testing-library/vue'
|
||||
|
||||
import { renderComponent } from '#tests/support/components/index.ts'
|
||||
import { mockUserCurrent } from '#tests/support/mock-userCurrent.ts'
|
||||
import { waitForNextTick } from '#tests/support/utils.ts'
|
||||
|
||||
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
|
||||
import type { BadgeVariant } from '#shared/components/CommonBadge/types.ts'
|
||||
import { useReactiveNow } from '#shared/composables/useReactiveNow.ts'
|
||||
import {
|
||||
EnumKnowledgeBaseSchedulableVisibility,
|
||||
EnumKnowledgeBaseVisibility,
|
||||
} from '#shared/graphql/types.ts'
|
||||
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
|
||||
import { i18n } from '#shared/i18n.ts'
|
||||
import { getBadgeClasses } from '#shared/initializer/initializeBadgeClasses.ts'
|
||||
|
||||
import KnowledgeBaseAnswerHeaderDetails from '../KnowledgeBaseAnswerHeaderDetails.vue'
|
||||
|
||||
@@ -14,9 +24,53 @@ const CURRENT_USER_ID = convertToGraphQLId('User', 1)
|
||||
|
||||
// A date still ahead of the moment the test runs, which is what a scheduled change looks like on
|
||||
// the record: the same three columns, only not reached yet.
|
||||
const inDays = (days: number) => new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString()
|
||||
//
|
||||
// The extra hour keeps each of them clear of the boundary the relative formatter floors at -
|
||||
// exactly two days from now comes out as "in 1 day" by the time the assertion runs.
|
||||
const hour = 60 * 60 * 1000
|
||||
const inDays = (days: number) => new Date(Date.now() + days * 24 * hour + hour).toISOString()
|
||||
const OTHER_USER_ID = convertToGraphQLId('User', 2)
|
||||
|
||||
// The clock both the reached dates and the scheduled change are read against. Driven by hand
|
||||
// instead of waited out: the real one is a module-level `useNow` whose interval is registered when
|
||||
// the composable is first imported, so fake timers installed in a `beforeEach` never reach it.
|
||||
//
|
||||
// The ref is created inside the factory and taken back out by calling the mock, rather than closed
|
||||
// over from here: `#shared/i18n` calls this composable while the imports are still being
|
||||
// evaluated, which is before any `const` of this file exists.
|
||||
vi.mock('#shared/composables/useReactiveNow.ts', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const now = ref(new Date())
|
||||
|
||||
return { useReactiveNow: () => now }
|
||||
})
|
||||
|
||||
const reactiveNow = useReactiveNow()
|
||||
|
||||
beforeEach(() => {
|
||||
reactiveNow.value = new Date()
|
||||
})
|
||||
|
||||
const schedule = (visibility: EnumKnowledgeBaseSchedulableVisibility, scheduledAt: string) => ({
|
||||
__typename: 'KnowledgeBaseAnswerVisibilitySchedule' as const,
|
||||
visibility,
|
||||
scheduledAt,
|
||||
})
|
||||
|
||||
const badgeClasses = getBadgeClasses()
|
||||
|
||||
// Every badge of the strip shares the `common-badge` test id, and the scheduled one carries no text
|
||||
// the visibility badge could not also carry - so it is found by the one thing that is unique to
|
||||
// it: the timestamp of the change it announces.
|
||||
//
|
||||
// Which is on the element because the badge asks `CommonDateTime` for `type="relative"` outright,
|
||||
// the way the reached-date badges do - so the `datetime` attribute does not depend on the
|
||||
// instance's `pretty_date_format`, and a badge without one would be the bug this looks for.
|
||||
const badgeFor = (view: ReturnType<typeof renderDetails>, scheduledAt: string) =>
|
||||
view.container
|
||||
.querySelector(`[datetime="${scheduledAt}"]`)
|
||||
?.closest<HTMLElement>('[data-test-id="common-badge"]') ?? null
|
||||
|
||||
const answer = (overrides: Partial<KnowledgeBaseAnswerHeader> = {}): KnowledgeBaseAnswerHeader =>
|
||||
({
|
||||
id: convertToGraphQLId('KnowledgeBase::Answer', 1),
|
||||
@@ -28,6 +82,7 @@ const answer = (overrides: Partial<KnowledgeBaseAnswerHeader> = {}): KnowledgeBa
|
||||
archivedAt: null,
|
||||
editedAt: null,
|
||||
editedBy: null,
|
||||
visibilitySchedules: null,
|
||||
category: { id: convertToGraphQLId('KnowledgeBase::Category', 1), breadcrumb: [] },
|
||||
...overrides,
|
||||
}) as KnowledgeBaseAnswerHeader
|
||||
@@ -54,6 +109,99 @@ describe('KnowledgeBaseAnswerHeaderDetails', () => {
|
||||
expect(view.getByText(label)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The badge names the state the *reached* dates put the answer in, not the `visibility` field:
|
||||
// the server derives that one once per request, so it keeps naming the state the answer was in
|
||||
// when the page loaded.
|
||||
describe('visibility derived from the reached dates', () => {
|
||||
// The first badge of the strip by construction: the visibility badge is the only one the
|
||||
// template renders unconditionally, and it heads the strip.
|
||||
const visibilityBadgeOf = (view: ReturnType<typeof renderDetails>) =>
|
||||
view.getAllByTestId('common-badge')[0]
|
||||
|
||||
const classesOf = (variant: BadgeVariant) => badgeClasses[variant].split(/\s+/).filter(Boolean)
|
||||
|
||||
// The regression: an answer left open past its scheduled archival used to keep a green
|
||||
// "PUBLISHED" badge while the tooltip beside it had already advanced to the archival date,
|
||||
// and the scheduled badge announcing that archival had already dropped off the strip.
|
||||
it('renames the badge once a scheduled archive falls due', async () => {
|
||||
const publishedAt = '2026-08-01T10:00:00Z'
|
||||
const archivedAt = inDays(1)
|
||||
|
||||
const view = renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Published,
|
||||
publishedAt,
|
||||
archivedAt,
|
||||
visibilitySchedules: [
|
||||
schedule(EnumKnowledgeBaseSchedulableVisibility.Archived, archivedAt),
|
||||
],
|
||||
})
|
||||
|
||||
expect(within(visibilityBadgeOf(view)).getByText('Published')).toBeInTheDocument()
|
||||
expect(visibilityBadgeOf(view)).toHaveClass(...classesOf('success'))
|
||||
expect(badgeFor(view, archivedAt)).not.toBeNull()
|
||||
|
||||
reactiveNow.value = new Date(inDays(2))
|
||||
await waitForNextTick()
|
||||
|
||||
// The state, its color and the date it is stamped with, all three moved together - and the
|
||||
// change is no longer announced as scheduled, because it has happened.
|
||||
expect(within(visibilityBadgeOf(view)).getByText('Archived')).toBeInTheDocument()
|
||||
expect(visibilityBadgeOf(view)).toHaveClass(...classesOf('tertiary'))
|
||||
expect(visibilityBadgeOf(view)).toHaveAttribute('aria-description', i18n.dateTime(archivedAt))
|
||||
expect(badgeFor(view, archivedAt)).toBeNull()
|
||||
})
|
||||
|
||||
// The publication of a draft, which is the same move one rung earlier: nothing is reached
|
||||
// before it falls due, so the badge starts on the field it was handed.
|
||||
it('renames a draft the moment it is published', async () => {
|
||||
const publishedAt = inDays(1)
|
||||
|
||||
const view = renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Draft,
|
||||
publishedAt,
|
||||
visibilitySchedules: [
|
||||
schedule(EnumKnowledgeBaseSchedulableVisibility.Published, publishedAt),
|
||||
],
|
||||
})
|
||||
|
||||
expect(within(visibilityBadgeOf(view)).getByText('Draft')).toBeInTheDocument()
|
||||
|
||||
reactiveNow.value = new Date(inDays(2))
|
||||
await waitForNextTick()
|
||||
|
||||
expect(within(visibilityBadgeOf(view)).getByText('Published')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// What the create header hands this component: the visibility being picked in the form, with no
|
||||
// stored answer and therefore no dates behind it. The field has to keep naming the badge
|
||||
// there, or every draft-in-progress would read "DRAFT" whatever the form says.
|
||||
it.each([
|
||||
[EnumKnowledgeBaseVisibility.Internal, 'Internal'],
|
||||
[EnumKnowledgeBaseVisibility.Published, 'Published'],
|
||||
[EnumKnowledgeBaseVisibility.Archived, 'Archived'],
|
||||
])('falls back to the %s it is handed when no date is reached', (visibility, label) => {
|
||||
const view = renderDetails({
|
||||
visibility,
|
||||
internalAt: null,
|
||||
publishedAt: null,
|
||||
archivedAt: null,
|
||||
})
|
||||
|
||||
expect(within(visibilityBadgeOf(view)).getByText(label)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// A date still ahead is a scheduled change, not history - so it may not rename the badge any
|
||||
// more than it may date it.
|
||||
it('leaves the badge alone for a date that is still ahead', () => {
|
||||
const view = renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Draft,
|
||||
publishedAt: inDays(7),
|
||||
})
|
||||
|
||||
expect(within(visibilityBadgeOf(view)).getByText('Draft')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// The badge carries no text of its own, so its accessible name is the whole warning - and the
|
||||
// only thing a view spec could see it by.
|
||||
it('warns about a missing translation when asked to', () => {
|
||||
@@ -80,69 +228,110 @@ describe('KnowledgeBaseAnswerHeaderDetails', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only the publication dates the answer actually carries', () => {
|
||||
const view = renderDetails({ publishedAt: '2026-08-01T10:00:00Z' })
|
||||
// The strip carries no badge per reached date any more: the answer's own history sits in a
|
||||
// tooltip on the visibility badge, saying when the state that badge names was reached.
|
||||
describe('reached date tooltip', () => {
|
||||
const INTERNAL_AT = '2026-07-01T10:00:00Z'
|
||||
const PUBLISHED_AT = '2026-08-01T10:00:00Z'
|
||||
const ARCHIVED_AT = '2026-08-05T10:00:00Z'
|
||||
|
||||
// Twice: the visibility badge and the publication date badge.
|
||||
expect(view.getAllByText('Published')).toHaveLength(2)
|
||||
expect(view.queryByText('Internally published')).not.toBeInTheDocument()
|
||||
expect(view.queryByText('Archived')).not.toBeInTheDocument()
|
||||
})
|
||||
// The first badge of the strip by construction: the visibility badge is the only one the
|
||||
// template renders unconditionally, and it heads the strip.
|
||||
const visibilityBadgeOf = (view: ReturnType<typeof renderDetails>) =>
|
||||
view.getAllByTestId('common-badge')[0]
|
||||
|
||||
it('shows the internal and archival dates when they are set', () => {
|
||||
const view = renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Archived,
|
||||
internalAt: '2026-07-01T10:00:00Z',
|
||||
archivedAt: '2026-08-05T10:00:00Z',
|
||||
// `aria-description`, which is where the `supportive` modifier puts the message - the badge
|
||||
// names its state in its own text, and a plain tooltip would replace that accessible name
|
||||
// with the bare timestamp.
|
||||
const tooltipOf = (view: ReturnType<typeof renderDetails>) =>
|
||||
visibilityBadgeOf(view).getAttribute('aria-description')
|
||||
|
||||
// The timestamp alone, the way the edit chip renders its own: the badge it hangs on already
|
||||
// says which state this is the date of.
|
||||
it.each([
|
||||
[EnumKnowledgeBaseVisibility.Internal, { internalAt: INTERNAL_AT }, INTERNAL_AT],
|
||||
[EnumKnowledgeBaseVisibility.Published, { publishedAt: PUBLISHED_AT }, PUBLISHED_AT],
|
||||
[EnumKnowledgeBaseVisibility.Archived, { archivedAt: ARCHIVED_AT }, ARCHIVED_AT],
|
||||
])('says when a %s answer reached its state', (visibility, dates, reachedAt) => {
|
||||
const view = renderDetails({ visibility, ...dates })
|
||||
|
||||
expect(tooltipOf(view)).toBe(i18n.dateTime(reachedAt))
|
||||
})
|
||||
|
||||
expect(view.getByText('Internally published')).toBeInTheDocument()
|
||||
// Twice: the visibility badge and the archival date badge.
|
||||
expect(view.getAllByText('Archived')).toHaveLength(2)
|
||||
})
|
||||
// The order of the state machine (internalAt < publishedAt < archivedAt) rather than the
|
||||
// order of the fields: an answer that has been internal, then published, then archived
|
||||
// carries all three dates, and only the last of them is the state it is in now.
|
||||
it('dates the last state the answer has reached', () => {
|
||||
const view = renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Archived,
|
||||
internalAt: INTERNAL_AT,
|
||||
publishedAt: PUBLISHED_AT,
|
||||
archivedAt: ARCHIVED_AT,
|
||||
})
|
||||
|
||||
// A publication state is stored as the date it is reached at, so a date still ahead is a
|
||||
// *scheduled* change. This strip says what the answer is - what it is going to become belongs
|
||||
// to the editor's sidebar section, and to nobody else's view.
|
||||
it('leaves out a date the answer has not reached yet', () => {
|
||||
const view = renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Draft,
|
||||
publishedAt: inDays(7),
|
||||
expect(tooltipOf(view)).toBe(i18n.dateTime(ARCHIVED_AT))
|
||||
})
|
||||
|
||||
expect(view.getByText('Draft')).toBeInTheDocument()
|
||||
expect(view.queryByText('Published')).not.toBeInTheDocument()
|
||||
})
|
||||
// A publication state is stored as the date it is reached at, so a date still ahead is a
|
||||
// *scheduled* change. The tooltip says what the answer is - what it is going to become
|
||||
// belongs to the scheduled badge beside it, and to nobody who may not edit the answer.
|
||||
it('ignores a date the answer has not reached yet', () => {
|
||||
const view = renderDetails({
|
||||
internalAt: INTERNAL_AT,
|
||||
publishedAt: PUBLISHED_AT,
|
||||
archivedAt: inDays(21),
|
||||
})
|
||||
|
||||
it('keeps the dates it has reached beside the ones it has not', () => {
|
||||
const view = renderDetails({
|
||||
internalAt: '2026-07-01T10:00:00Z',
|
||||
publishedAt: '2026-08-01T10:00:00Z',
|
||||
archivedAt: inDays(21),
|
||||
expect(tooltipOf(view)).toBe(i18n.dateTime(PUBLISHED_AT))
|
||||
})
|
||||
|
||||
expect(view.getByText('Internally published')).toBeInTheDocument()
|
||||
// Twice: the visibility badge and the publication date badge.
|
||||
expect(view.getAllByText('Published')).toHaveLength(2)
|
||||
expect(view.queryByText('Archived')).not.toBeInTheDocument()
|
||||
})
|
||||
// A draft stores no date of its own, so it has no timestamp label either - and its
|
||||
// publication date, if it has one, is by definition still ahead.
|
||||
it('offers no tooltip for a draft', () => {
|
||||
const view = renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Draft,
|
||||
publishedAt: inDays(7),
|
||||
})
|
||||
|
||||
// What a user without knowledge base permission receives: the backend nulls the internal
|
||||
// lifecycle, leaving the badge and the publication date - which it only ever has reached.
|
||||
it('shows only the publication date for a public reader', () => {
|
||||
const view = renderDetails({
|
||||
publishedAt: '2026-08-01T10:00:00Z',
|
||||
internalAt: null,
|
||||
archivedAt: null,
|
||||
editedAt: null,
|
||||
editedBy: null,
|
||||
expect(view.getByText('Draft')).toBeInTheDocument()
|
||||
expect(visibilityBadgeOf(view)).not.toHaveAttribute('aria-description')
|
||||
})
|
||||
|
||||
// Twice: the visibility badge and the publication date badge.
|
||||
expect(view.getAllByText('Published')).toHaveLength(2)
|
||||
expect(view.queryByText('Internally published')).not.toBeInTheDocument()
|
||||
expect(view.queryByText('Archived')).not.toBeInTheDocument()
|
||||
expect(view.queryByText(/edited/)).not.toBeInTheDocument()
|
||||
it('offers no tooltip before any date is reached', () => {
|
||||
const view = renderDetails()
|
||||
|
||||
expect(visibilityBadgeOf(view)).not.toHaveAttribute('aria-description')
|
||||
})
|
||||
|
||||
// What a user without knowledge base permission receives: the backend nulls the internal
|
||||
// lifecycle, leaving the badge and the publication date - which it only ever has reached.
|
||||
it('dates the publication for a public reader', () => {
|
||||
const view = renderDetails({
|
||||
publishedAt: PUBLISHED_AT,
|
||||
internalAt: null,
|
||||
archivedAt: null,
|
||||
editedAt: null,
|
||||
editedBy: null,
|
||||
})
|
||||
|
||||
expect(tooltipOf(view)).toBe(i18n.dateTime(PUBLISHED_AT))
|
||||
expect(view.queryByText(/edited/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The tooltip ticks on the clock the scheduled badge does, so a page left open past a due
|
||||
// date stops dating a state the answer has already left behind.
|
||||
it('advances to a date the moment it falls due', async () => {
|
||||
const archivedAt = inDays(1)
|
||||
|
||||
const view = renderDetails({ publishedAt: PUBLISHED_AT, archivedAt })
|
||||
|
||||
expect(tooltipOf(view)).toBe(i18n.dateTime(PUBLISHED_AT))
|
||||
|
||||
reactiveNow.value = new Date(inDays(2))
|
||||
await waitForNextTick()
|
||||
|
||||
expect(tooltipOf(view)).toBe(i18n.dateTime(archivedAt))
|
||||
})
|
||||
})
|
||||
|
||||
it('renders no edit chip without an edit date', () => {
|
||||
@@ -189,4 +378,262 @@ describe('KnowledgeBaseAnswerHeaderDetails', () => {
|
||||
expect(view.getByText(/^edited /)).toBeInTheDocument()
|
||||
expect(view.queryByText(/ by /)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('next scheduled visibility', () => {
|
||||
// Both negative cases pin the whole strip rather than the absence of one label: the fixture
|
||||
// leaves a bare draft with no dates, no warning and no editor, so the visibility badge is the
|
||||
// only badge there is - and a scheduled badge under any label would break the count.
|
||||
//
|
||||
// Denied rather than emptied for whoever may not edit the answer, so the field's absence is the
|
||||
// permission check - this component makes none of its own.
|
||||
it('renders no badge for a user who may not edit the answer', () => {
|
||||
const view = renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Draft,
|
||||
visibilitySchedules: null,
|
||||
})
|
||||
|
||||
expect(view.queryAllByTestId('common-badge')).toHaveLength(1)
|
||||
expect(view.getByText('Draft')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// An editor being told that nothing is scheduled, which is not a badge either.
|
||||
it('renders no badge for an editor with nothing scheduled', () => {
|
||||
const view = renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Draft,
|
||||
visibilitySchedules: [],
|
||||
})
|
||||
|
||||
expect(view.queryAllByTestId('common-badge')).toHaveLength(1)
|
||||
expect(view.getByText('Draft')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('names the scheduled state and when it takes effect', () => {
|
||||
const scheduledAt = inDays(3)
|
||||
|
||||
const view = renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Draft,
|
||||
visibilitySchedules: [
|
||||
schedule(EnumKnowledgeBaseSchedulableVisibility.Internal, scheduledAt),
|
||||
],
|
||||
})
|
||||
|
||||
const badge = badgeFor(view, scheduledAt)
|
||||
|
||||
expect(badge).not.toBeNull()
|
||||
expect(within(badge!).getByText('Internal')).toBeInTheDocument()
|
||||
// The answer is still a draft - the badge says what it is going to become, and the visibility
|
||||
// badge at the head of the strip keeps saying what it is.
|
||||
expect(view.getByText('Draft')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// `badgeVariants` is shared with the visibility badge, so a scheduled state is tinted exactly as
|
||||
// it will be once it arrives: internal blue, published green, archived grey.
|
||||
it.each([
|
||||
[EnumKnowledgeBaseSchedulableVisibility.Internal, 'Internal', 'info'],
|
||||
[EnumKnowledgeBaseSchedulableVisibility.Published, 'Published', 'success'],
|
||||
[EnumKnowledgeBaseSchedulableVisibility.Archived, 'Archived', 'tertiary'],
|
||||
])('colours the badge for a scheduled %s', (visibility, label, variant) => {
|
||||
const scheduledAt = inDays(3)
|
||||
|
||||
const view = renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Draft,
|
||||
visibilitySchedules: [schedule(visibility, scheduledAt)],
|
||||
})
|
||||
|
||||
const badge = badgeFor(view, scheduledAt)
|
||||
|
||||
expect(badge).not.toBeNull()
|
||||
expect(within(badge!).getByText(label)).toBeInTheDocument()
|
||||
// The variant, not the rendered hue: `initializeGlobalComponentStyles` does not run under
|
||||
// Vitest, so this is the placeholder map - which is the one `CommonBadge` reads too.
|
||||
expect(badge).toHaveClass(
|
||||
...badgeClasses[variant as BadgeVariant].split(/\s+/).filter(Boolean),
|
||||
)
|
||||
})
|
||||
|
||||
// "In case of multiple scheduled visibilities, only the next one scheduled is shown" - the rest
|
||||
// are the sidebar's list, and the popover that will hang off this badge.
|
||||
it('announces only the next of several scheduled changes', () => {
|
||||
const next = inDays(2)
|
||||
const later = inDays(9)
|
||||
|
||||
const view = renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Draft,
|
||||
visibilitySchedules: [
|
||||
schedule(EnumKnowledgeBaseSchedulableVisibility.Internal, next),
|
||||
schedule(EnumKnowledgeBaseSchedulableVisibility.Published, later),
|
||||
],
|
||||
})
|
||||
|
||||
expect(badgeFor(view, next)).not.toBeNull()
|
||||
expect(badgeFor(view, later)).toBeNull()
|
||||
expect(view.queryByText('Published')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The badge ticks on the clock its neighbours do, so a page left open past a due date corrects
|
||||
// itself: the change stops being scheduled here at the moment it becomes a reached date.
|
||||
it('drops the badge once the schedule falls due', async () => {
|
||||
const scheduledAt = inDays(1)
|
||||
|
||||
const view = renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Draft,
|
||||
visibilitySchedules: [
|
||||
schedule(EnumKnowledgeBaseSchedulableVisibility.Internal, scheduledAt),
|
||||
],
|
||||
})
|
||||
|
||||
expect(badgeFor(view, scheduledAt)).not.toBeNull()
|
||||
|
||||
reactiveNow.value = new Date(inDays(2))
|
||||
await waitForNextTick()
|
||||
|
||||
expect(badgeFor(view, scheduledAt)).toBeNull()
|
||||
})
|
||||
|
||||
// Both halves of the same three columns at once: a publication the answer has reached, and an
|
||||
// archival still ahead of it.
|
||||
it('leaves the reached date the visibility badge carries alone', () => {
|
||||
const publishedAt = '2026-08-01T10:00:00Z'
|
||||
const scheduledAt = inDays(5)
|
||||
|
||||
const view = renderDetails({
|
||||
publishedAt,
|
||||
archivedAt: scheduledAt,
|
||||
visibilitySchedules: [
|
||||
schedule(EnumKnowledgeBaseSchedulableVisibility.Archived, scheduledAt),
|
||||
],
|
||||
})
|
||||
|
||||
// Two badges, no more: the visibility badge and the scheduled one. The reached publication
|
||||
// is a tooltip on the first of them rather than a badge of its own.
|
||||
expect(view.getAllByTestId('common-badge')).toHaveLength(2)
|
||||
expect(view.getAllByTestId('common-badge')[0]).toHaveAttribute(
|
||||
'aria-description',
|
||||
i18n.dateTime(publishedAt),
|
||||
)
|
||||
// Once: the scheduled badge only - the archival date has not been reached.
|
||||
expect(view.getAllByText('Archived')).toHaveLength(1)
|
||||
expect(badgeFor(view, scheduledAt)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// The badge announces the next change; the popover on it is where the rest of them are reachable.
|
||||
describe('scheduled visibility popover', () => {
|
||||
const threeSchedules = [
|
||||
schedule(EnumKnowledgeBaseSchedulableVisibility.Internal, inDays(2)),
|
||||
schedule(EnumKnowledgeBaseSchedulableVisibility.Published, inDays(8)),
|
||||
schedule(EnumKnowledgeBaseSchedulableVisibility.Archived, inDays(31)),
|
||||
]
|
||||
|
||||
const renderWithSchedules = () =>
|
||||
renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Draft,
|
||||
visibilitySchedules: threeSchedules,
|
||||
})
|
||||
|
||||
// Matched on the prefix, so the exact relative wording stays the business of the name test
|
||||
// below rather than of every case that needs the trigger.
|
||||
const triggerOf = (view: ReturnType<typeof renderDetails>) =>
|
||||
view.getByRole('button', { name: /^Scheduled visibility:/ })
|
||||
|
||||
// `role="button"` makes the trigger's children presentational, so the badge's own text is gone
|
||||
// from the accessibility tree - the name has to carry the state and its timing itself, or a
|
||||
// screen reader user would have to open the popover to learn what a sighted user reads.
|
||||
it('announces the change the badge shows', () => {
|
||||
const view = renderWithSchedules()
|
||||
|
||||
expect(
|
||||
view.getByRole('button', { name: 'Scheduled visibility: Internal in 2 days' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists every scheduled change on hover, not only the one on the badge', async () => {
|
||||
const view = renderWithSchedules()
|
||||
|
||||
await view.events.hover(triggerOf(view))
|
||||
|
||||
const popover = await view.findByRole('region')
|
||||
|
||||
// All three, where the badge shows the first alone.
|
||||
expect(within(popover).getAllByRole('listitem')).toHaveLength(3)
|
||||
expect(within(popover).getByText('Internal')).toBeInTheDocument()
|
||||
expect(within(popover).getByText('Published')).toBeInTheDocument()
|
||||
expect(within(popover).getByText('Archived')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says when each of them happens', async () => {
|
||||
const view = renderWithSchedules()
|
||||
|
||||
await view.events.hover(triggerOf(view))
|
||||
|
||||
const popover = await view.findByRole('region')
|
||||
|
||||
expect(within(popover).getByText('Scheduled visibility')).toBeInTheDocument()
|
||||
expect(within(popover).getByText('in 2 days')).toBeInTheDocument()
|
||||
expect(within(popover).getByText('in 1 week')).toBeInTheDocument()
|
||||
expect(within(popover).getByText('in 1 month')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// What a bare hover handler would not answer: the trigger is focusable and opens from the
|
||||
// keyboard, which is `CommonPopoverWithTrigger`'s doing rather than this component's.
|
||||
// Focused without the pointer, deliberately: `events.type` would click first, and the pointer
|
||||
// events that come with a click open the popover through the hover watcher - the assertion
|
||||
// would then hold with the `Space` handler deleted.
|
||||
it('opens from the keyboard', async () => {
|
||||
const view = renderWithSchedules()
|
||||
|
||||
triggerOf(view).focus()
|
||||
await view.events.keyboard('{Space}')
|
||||
|
||||
const popover = await view.findByRole('region')
|
||||
|
||||
expect(within(popover).getAllByRole('listitem')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('closes again when the pointer leaves', async () => {
|
||||
const view = renderWithSchedules()
|
||||
|
||||
await view.events.hover(triggerOf(view))
|
||||
expect(await view.findByRole('region')).toBeInTheDocument()
|
||||
|
||||
await view.events.unhover(triggerOf(view))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(view.queryByRole('region')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// The badge advances past a change that falls due while the page is open; the list behind it has
|
||||
// to do the same, or it would render a *scheduled* change in the past tense ("1 minute ago")
|
||||
// directly under a badge that has already moved on.
|
||||
it('drops a change that falls due from the list as well', async () => {
|
||||
const view = renderWithSchedules()
|
||||
|
||||
await view.events.hover(triggerOf(view))
|
||||
expect(within(await view.findByRole('region')).getAllByRole('listitem')).toHaveLength(3)
|
||||
|
||||
reactiveNow.value = new Date(inDays(3))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(view.getByRole('region')).getAllByRole('listitem')).toHaveLength(2)
|
||||
})
|
||||
|
||||
const popover = view.getByRole('region')
|
||||
|
||||
expect(within(popover).queryByText('Internal')).not.toBeInTheDocument()
|
||||
expect(within(popover).getByText('Published')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Nothing gates the popover of its own: no schedule means no badge, and the badge is the
|
||||
// trigger.
|
||||
it('offers no trigger at all when nothing is scheduled', () => {
|
||||
const view = renderDetails({
|
||||
visibility: EnumKnowledgeBaseVisibility.Draft,
|
||||
visibilitySchedules: [],
|
||||
})
|
||||
|
||||
expect(view.queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(view.queryByRole('region')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,6 +44,12 @@ export type KnowledgeBaseAnswerHeaderDetailsAnswer = Pick<KnowledgeBaseAnswerHea
|
||||
Partial<
|
||||
Pick<
|
||||
KnowledgeBaseAnswerHeader,
|
||||
'translationMissing' | 'internalAt' | 'publishedAt' | 'archivedAt' | 'editedAt' | 'editedBy'
|
||||
| 'translationMissing'
|
||||
| 'internalAt'
|
||||
| 'publishedAt'
|
||||
| 'archivedAt'
|
||||
| 'editedAt'
|
||||
| 'editedBy'
|
||||
| 'visibilitySchedules'
|
||||
>
|
||||
>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import { useReactiveNow } from '#shared/composables/useReactiveNow.ts'
|
||||
import type { EnumKnowledgeBaseSchedulableVisibility } from '#shared/graphql/types.ts'
|
||||
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
interface KnowledgeBaseAnswerVisibilitySchedule {
|
||||
visibility: EnumKnowledgeBaseSchedulableVisibility
|
||||
scheduledAt: string
|
||||
}
|
||||
|
||||
interface KnowledgeBaseAnswerVisibilitySchedules {
|
||||
visibilitySchedules?: Maybe<KnowledgeBaseAnswerVisibilitySchedule[]>
|
||||
}
|
||||
|
||||
// The forward-looking counterpart of useKnowledgeBaseAnswerReachedDates: that one keeps the dates
|
||||
// the answer has already reached, this one the next change it is going to make.
|
||||
//
|
||||
// Neither sorted nor filtered beyond the clock, because the field arrives that way already:
|
||||
// CanBePublished#visibility_schedules returns future-only entries in the order they take effect -
|
||||
// the ordering validations behind it only let the timestamps run in rank order - so the first
|
||||
// entry still ahead is the next one to fire.
|
||||
//
|
||||
// Which leaves the clock a single job, and it is the reason this filters at all rather than handing
|
||||
// the field over as it arrives: a page left open past a due date. Reactive against
|
||||
// `useReactiveNow` for it, the very clock the reached-date badges tick on - so a schedule falling
|
||||
// due stops being a schedule here at the moment it becomes a reached date over there.
|
||||
//
|
||||
// Absent rather than empty for anybody who may not edit the answer: the field is denied to them
|
||||
// (Gql::Types::KnowledgeBase::AnswerType#visibility_schedules, gated on AnswerPolicy#update?), so
|
||||
// its absence is the permission check and no caller needs one of its own.
|
||||
export const useKnowledgeBaseAnswerNextVisibilitySchedule = (
|
||||
answer: MaybeRefOrGetter<KnowledgeBaseAnswerVisibilitySchedules>,
|
||||
) => {
|
||||
const reactiveNow = useReactiveNow()
|
||||
|
||||
// Every change that is still ahead, and the next of them - one clock for both, so a badge and the
|
||||
// list behind it can never disagree about whether a change has already happened.
|
||||
const pendingVisibilitySchedules = computed(
|
||||
() =>
|
||||
toValue(answer).visibilitySchedules?.filter(
|
||||
({ scheduledAt }) => new Date(scheduledAt).getTime() > reactiveNow.value.getTime(),
|
||||
) ?? [],
|
||||
)
|
||||
|
||||
const nextVisibilitySchedule = computed(() => pendingVisibilitySchedules.value[0])
|
||||
|
||||
return { nextVisibilitySchedule, pendingVisibilitySchedules }
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export const knowledgeBaseVisibilityMeta = {
|
||||
[EnumKnowledgeBaseVisibility.Published]: { icon: 'unlock-fill', class: 'text-green-400!' },
|
||||
[EnumKnowledgeBaseVisibility.Archived]: {
|
||||
icon: 'archive-fill',
|
||||
class: 'text-neutral-400! dark:text-neutral-600!',
|
||||
class: 'text-gray-100! dark:text-neutral-400!',
|
||||
},
|
||||
} as const
|
||||
|
||||
|
||||
@@ -6,10 +6,15 @@ import { computed } from 'vue'
|
||||
import CommonDateTime from '#shared/components/CommonDateTime/CommonDateTime.vue'
|
||||
import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
|
||||
import CommonLink from '#shared/components/CommonLink/CommonLink.vue'
|
||||
import type { KnowledgeBaseAnswerTranslationFragment } from '#shared/graphql/types.ts'
|
||||
import {
|
||||
EnumKnowledgeBaseVisibility,
|
||||
type KnowledgeBaseAnswerTranslationFragment,
|
||||
} from '#shared/graphql/types.ts'
|
||||
|
||||
import CommonObjectAttribute from '#desktop/components/CommonObjectAttribute/CommonObjectAttribute.vue'
|
||||
import CommonObjectAttributeContainer from '#desktop/components/CommonObjectAttribute/CommonObjectAttributeContainer.vue'
|
||||
import { visibilityMeta } from '#desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts'
|
||||
import { useKnowledgeBaseAnswerReachedDates } from '#desktop/entities/knowledge-base/composables/useKnowledgeBaseAnswerReachedDates.ts'
|
||||
import { knowledgeBaseBrowseRoute } from '#desktop/entities/knowledge-base/utils/routeLocation.ts'
|
||||
import { tagSearchRoute } from '#desktop/entities/tags/utils/routeLocation.ts'
|
||||
|
||||
@@ -20,6 +25,12 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const answer = computed(() => props.translation.answer)
|
||||
|
||||
// The three publication columns carry the answer's history and its scheduled changes at once, so
|
||||
// the rows below read the clock-filtered dates rather than the fields as they arrived: an
|
||||
// archival scheduled for next week is not something this panel may date as "Archived".
|
||||
const { reachedDates } = useKnowledgeBaseAnswerReachedDates(answer)
|
||||
|
||||
const category = computed(() => answer.value.category)
|
||||
const systemLocale = computed(() => props.translation.kbLocale.systemLocale)
|
||||
const tags = computed(() => answer.value.tags ?? [])
|
||||
@@ -43,16 +54,25 @@ const categoryPath = computed(() => {
|
||||
<CommonObjectAttributeContainer
|
||||
class="grid-cols-1! *:col-span-1 @lg:grid-cols-2! @3xl:grid-cols-3!"
|
||||
>
|
||||
<CommonObjectAttribute v-if="answer.internalAt" :label="__('Internally published')">
|
||||
<CommonDateTime :date-time="answer.internalAt" type="relative" />
|
||||
<CommonObjectAttribute
|
||||
v-if="reachedDates.internalAt"
|
||||
:label="visibilityMeta[EnumKnowledgeBaseVisibility.Internal].timestampLabel"
|
||||
>
|
||||
<CommonDateTime :date-time="reachedDates.internalAt" type="relative" />
|
||||
</CommonObjectAttribute>
|
||||
|
||||
<CommonObjectAttribute v-if="answer.publishedAt" :label="__('Published')">
|
||||
<CommonDateTime :date-time="answer.publishedAt" type="relative" />
|
||||
<CommonObjectAttribute
|
||||
v-if="reachedDates.publishedAt"
|
||||
:label="visibilityMeta[EnumKnowledgeBaseVisibility.Published].timestampLabel"
|
||||
>
|
||||
<CommonDateTime :date-time="reachedDates.publishedAt" type="relative" />
|
||||
</CommonObjectAttribute>
|
||||
|
||||
<CommonObjectAttribute v-if="answer.archivedAt" :label="__('Archived')">
|
||||
<CommonDateTime :date-time="answer.archivedAt" type="relative" />
|
||||
<CommonObjectAttribute
|
||||
v-if="reachedDates.archivedAt"
|
||||
:label="visibilityMeta[EnumKnowledgeBaseVisibility.Archived].timestampLabel"
|
||||
>
|
||||
<CommonDateTime :date-time="reachedDates.archivedAt" type="relative" />
|
||||
</CommonObjectAttribute>
|
||||
|
||||
<CommonObjectAttribute v-if="category.title" :label="__('Category')">
|
||||
|
||||
@@ -129,6 +129,18 @@ describe('TicketKnowledgeBaseAnswerPopover', () => {
|
||||
expect(wrapper.getByText('Archived')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// A publication state is stored as the date it is reached at, so a date still ahead is a
|
||||
// *scheduled* change rather than the answer's history. Dating a row "Archived" from it would
|
||||
// have this panel claim an answer is archived a week before it is.
|
||||
it('leaves out a date the answer has not reached yet', () => {
|
||||
const nextWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
const wrapper = renderPopover(buildTranslation({ answer: { archivedAt: nextWeek } }))
|
||||
|
||||
expect(wrapper.getByText('Published')).toBeInTheDocument()
|
||||
expect(wrapper.queryByText('Archived')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the category row when the category has no title in the requested locale', () => {
|
||||
const wrapper = renderPopover(buildTranslation({ answer: { category: { title: null } } }))
|
||||
|
||||
|
||||
@@ -2046,12 +2046,9 @@ msgid "Archive ticket target state"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee:113
|
||||
#: app/frontend/apps/desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts:29
|
||||
#: app/frontend/apps/desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts:37
|
||||
#: app/frontend/apps/desktop/entities/knowledge-base/form/answerVisibilityOptions.ts:32
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerSidebarContent.vue:49
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseIconStatus.vue:35
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerHeaderDetails.vue:112
|
||||
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent/TicketRelatedKnowledge/KnowledgeBaseAnswerAttributes.vue:54
|
||||
msgid "Archived"
|
||||
msgstr ""
|
||||
|
||||
@@ -3182,7 +3179,7 @@ msgstr ""
|
||||
#: app/assets/javascripts/app/views/popover/kb_generic.jst.eco:36
|
||||
#: app/assets/javascripts/app/views/ticket_zoom/knowledge_base_ai_draft_modal.jst.eco:78
|
||||
#: app/frontend/apps/desktop/entities/knowledge-base/composables/useAnswerFormSchema.ts:186
|
||||
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent/TicketRelatedKnowledge/KnowledgeBaseAnswerAttributes.vue:58
|
||||
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent/TicketRelatedKnowledge/KnowledgeBaseAnswerAttributes.vue:78
|
||||
msgid "Category"
|
||||
msgstr ""
|
||||
|
||||
@@ -6363,7 +6360,7 @@ msgstr ""
|
||||
msgid "Do you really want to delete your current avatar?"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:83
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:67
|
||||
msgid "Do you really want to remove the scheduled change to %s?"
|
||||
msgstr ""
|
||||
|
||||
@@ -6490,7 +6487,7 @@ msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee:101
|
||||
#: app/assets/javascripts/app/views/ticket_zoom/attribute_bar.jst.eco:18
|
||||
#: app/frontend/apps/desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts:14
|
||||
#: app/frontend/apps/desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts:20
|
||||
#: app/frontend/apps/desktop/entities/knowledge-base/form/answerVisibilityOptions.ts:17
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseIconStatus.vue:29
|
||||
msgid "Draft"
|
||||
@@ -9580,7 +9577,7 @@ msgid "Integrity verification of the downloaded WhatsApp media failed."
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee:105
|
||||
#: app/frontend/apps/desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts:19
|
||||
#: app/frontend/apps/desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts:25
|
||||
#: app/frontend/apps/desktop/components/Ticket/TicketBulkEditFlyout/TicketBulkEditFlyout.vue:144
|
||||
#: app/frontend/apps/desktop/entities/knowledge-base/form/answerVisibilityOptions.ts:22
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseIconStatus.vue:31
|
||||
@@ -9604,9 +9601,7 @@ msgstr ""
|
||||
msgid "Internal record of the embedding model and vector size the knowledge base index was last built with. Used to detect a stale index by comparison, without probing Elasticsearch."
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerSidebarContent.vue:41
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerHeaderDetails.vue:96
|
||||
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent/TicketRelatedKnowledge/KnowledgeBaseAnswerAttributes.vue:46
|
||||
#: app/frontend/apps/desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts:26
|
||||
msgid "Internally published"
|
||||
msgstr ""
|
||||
|
||||
@@ -10138,7 +10133,7 @@ msgstr ""
|
||||
#: app/assets/javascripts/app/views/profile/language.jst.eco:3
|
||||
#: app/assets/javascripts/app/views/ticket_zoom/knowledge_base_ai_draft_modal.jst.eco:86
|
||||
#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSetting/plugins/locale.ts:6
|
||||
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent/TicketRelatedKnowledge/KnowledgeBaseAnswerAttributes.vue:70
|
||||
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent/TicketRelatedKnowledge/KnowledgeBaseAnswerAttributes.vue:90
|
||||
#: app/frontend/apps/mobile/pages/account/views/AccountOverview.vue:149
|
||||
#: db/seeds/permissions.rb:456
|
||||
msgid "Language"
|
||||
@@ -10260,7 +10255,7 @@ msgstr ""
|
||||
msgid "Last sync at:"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerSidebarContent.vue:53
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerSidebarContent.vue:62
|
||||
msgid "Last updated"
|
||||
msgstr ""
|
||||
|
||||
@@ -12383,7 +12378,7 @@ msgstr ""
|
||||
#: app/assets/javascripts/app/views/knowledge_base/content.jst.eco:2
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseBrowse/KnowledgeBaseAnswerCard.vue:98
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseBrowse/KnowledgeBaseCategoryCard.vue:117
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerHeaderDetails.vue:84
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerHeaderDetails.vue:148
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerTopBarHeader.vue:172
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseTopBarHeader.vue:206
|
||||
msgid "No translation available for this locale"
|
||||
@@ -12409,7 +12404,7 @@ msgstr ""
|
||||
msgid "No valid knowledge base category provided."
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:164
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:135
|
||||
msgid "No visibility changes scheduled yet."
|
||||
msgstr ""
|
||||
|
||||
@@ -14251,11 +14246,8 @@ msgstr ""
|
||||
msgid "Publish Article"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts:24
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerSidebarContent.vue:45
|
||||
#: app/frontend/apps/desktop/components/KnowledgeBaseAnswerIcon/visibilityMeta.ts:31
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseIconStatus.vue:33
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerHeaderDetails.vue:104
|
||||
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent/TicketRelatedKnowledge/KnowledgeBaseAnswerAttributes.vue:50
|
||||
#: app/helpers/knowledge_base_top_bar_helper.rb:34
|
||||
msgid "Published"
|
||||
msgstr ""
|
||||
@@ -14590,7 +14582,7 @@ msgstr ""
|
||||
#: app/frontend/apps/desktop/components/Form/fields/FieldGroupPermissions/FieldGroupPermissionsInput.vue:255
|
||||
#: app/frontend/apps/desktop/components/TwoFactor/TwoFactorConfiguration/TwoFactorConfigurationPasswordCheck.vue:59
|
||||
#: app/frontend/apps/desktop/components/TwoFactor/TwoFactorConfiguration/TwoFactorConfigurationSecurityKeys.vue:149
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:85
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:69
|
||||
#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingTwoFactorAuth.vue:178
|
||||
#: app/frontend/shared/form/i18n/locales.ts:29
|
||||
msgid "Remove"
|
||||
@@ -14678,7 +14670,7 @@ msgstr ""
|
||||
msgid "Remove notification"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:85
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:69
|
||||
msgid "Remove scheduled visibility change"
|
||||
msgstr ""
|
||||
|
||||
@@ -14690,7 +14682,7 @@ msgstr ""
|
||||
msgid "Remove selected values"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:152
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:123
|
||||
msgid "Remove this scheduled visibility change"
|
||||
msgstr ""
|
||||
|
||||
@@ -15391,7 +15383,7 @@ msgstr ""
|
||||
msgid "Schedule for"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:171
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:142
|
||||
msgid "Schedule visibility change"
|
||||
msgstr ""
|
||||
|
||||
@@ -15400,14 +15392,19 @@ msgstr ""
|
||||
msgid "Scheduled for"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:109
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:93
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerScheduledVisibilityPopover.vue:30
|
||||
msgid "Scheduled visibility"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:109
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:93
|
||||
msgid "Scheduled visibility change removed successfully."
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerHeaderDetails.vue:100
|
||||
msgid "Scheduled visibility: %s"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/_modal_generic_history.coffee:41
|
||||
#: app/assets/javascripts/app/controllers/job.coffee:3
|
||||
#: app/frontend/apps/desktop/components/CommonHistoryFlyout/utils/historyEventIssuerNames.ts:4
|
||||
@@ -17393,7 +17390,7 @@ msgstr ""
|
||||
#: app/frontend/apps/desktop/entities/knowledge-base/composables/useAnswerFormSchema.ts:133
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerTags.vue:134
|
||||
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent.vue:143
|
||||
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent/TicketRelatedKnowledge/KnowledgeBaseAnswerAttributes.vue:76
|
||||
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent/TicketRelatedKnowledge/KnowledgeBaseAnswerAttributes.vue:96
|
||||
#: app/frontend/apps/mobile/components/Form/fields/FieldTags/FieldTagsDialog.vue:143
|
||||
#: app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/TicketTags.vue:75
|
||||
#: app/frontend/apps/mobile/pages/ticket/views/TicketInformation/TicketInformationDetails.vue:164
|
||||
@@ -18680,7 +18677,7 @@ msgstr ""
|
||||
msgid "The rules instruct the AI agent on when to generate new tags and when to prefer tags from existing tags list."
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:65
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerScheduledVisibility.vue:49
|
||||
msgid "The scheduled visibility change could not be removed."
|
||||
msgstr ""
|
||||
|
||||
@@ -21018,7 +21015,7 @@ msgstr ""
|
||||
#: app/assets/javascripts/app/models/ticket.coffee:29
|
||||
#: app/assets/javascripts/app/models/ticket_article.coffee:17
|
||||
#: app/assets/javascripts/app/models/user.coffee:25
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerSidebarContent.vue:57
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseAnswer/KnowledgeBaseAnswerSidebar/KnowledgeBaseAnswerSidebarContent.vue:66
|
||||
#: app/frontend/shared/entities/organization/stores/objectAttributes.ts:31
|
||||
#: app/frontend/shared/entities/ticket/stores/objectAttributes.ts:109
|
||||
#: app/frontend/shared/entities/user/stores/objectAttributes.ts:33
|
||||
@@ -22997,17 +22994,17 @@ msgstr ""
|
||||
msgid "edit"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerHeaderDetails.vue:60
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerHeaderDetails.vue:116
|
||||
#: app/frontend/apps/mobile/composables/useEditedBy.ts:41
|
||||
msgid "edited %s"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerHeaderDetails.vue:64
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerHeaderDetails.vue:120
|
||||
#: app/frontend/apps/mobile/composables/useEditedBy.ts:44
|
||||
msgid "edited %s by %s"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerHeaderDetails.vue:62
|
||||
#: app/frontend/apps/desktop/pages/knowledge-base/components/KnowledgeBaseTopBarHeader/KnowledgeBaseAnswerHeaderDetails.vue:118
|
||||
#: app/frontend/apps/mobile/composables/useEditedBy.ts:41
|
||||
msgid "edited %s by me"
|
||||
msgstr ""
|
||||
|
||||
Reference in New Issue
Block a user