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:
Dusan Vuckovic
2026-09-02 14:31:41 +01:00
parent 0f316bad9e
commit 4117dc9eae
18 changed files with 1006 additions and 209 deletions

View File

@@ -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 }
>

View File

@@ -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 }
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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,
},
},
],
},
]

View File

@@ -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([])

View File

@@ -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>

View File

@@ -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()
})
})

View File

@@ -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

View File

@@ -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"

View File

@@ -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>

View File

@@ -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()
})
})
})

View File

@@ -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'
>
>

View File

@@ -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 }
}

View File

@@ -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

View File

@@ -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')">

View File

@@ -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 } } }))

View File

@@ -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 ""