feat: delete a custom type
This commit is contained in:
1 parent
76e9e1220c
commit
5404291af2
25 files changed
+1768
-46
No files matched your search
@@ -0,0 +1,157 @@
|
||||
Feature: Delete a custom content type from the Schema builder
|
||||
As an owner or admin
|
||||
I want to delete a custom type that has no entries
|
||||
So that unused types leave the schema, the API and the sidebar
|
||||
|
||||
Background:
|
||||
Given the SPA calls the API with relative URLs under /v1 and the "jwt" HttpOnly cookie
|
||||
And API errors are plain text
|
||||
And the edit builder, its store, entry count, leave guard, chip and banners of features/schema_builder_edit.feature are reused unchanged unless a scenario below says otherwise
|
||||
And DELETE /v1/content-type/{key} answers 204 with no body
|
||||
And DELETE answers 404 content_type_not_found, 409 content_type_has_entries, 403 or 500
|
||||
And the number of entries is the "total" already loaded from GET /v1/content-type/{key}/entry?page=1&size=5; no other request is made to open a dialog
|
||||
And the dialogs are DeleteTypeDialog (UModal + UAlert, color "error" on the destructive action; a bottom sheet on mobile) from docs/design/screens/schema.md group "3 Review and delete", states 5 to 7
|
||||
And every string goes through t() and has a French entry in i18n/messages.ts
|
||||
And a copy tagged "(proposed copy)" is not in the design and is the coder's single point of change
|
||||
|
||||
Scenario: The overflow menu (proposed placement, see open question 1)
|
||||
Given an owner opens /schema/recipes
|
||||
Then the top bar holds an icon button "More actions" (proposed copy) with the ellipsis icon, after Discard and Save on desktop and as the topbar action on mobile
|
||||
When the user activates it
|
||||
Then a menu opens with one item "Delete content type" with a trash-2 icon in the error colour
|
||||
And Esc or a click outside closes it and returns focus to the button
|
||||
|
||||
Scenario Outline: Where the menu is available
|
||||
Given <page>
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| page | result |
|
||||
| /schema/new | no overflow menu |
|
||||
| /schema/articles (built-in, read-only) | no overflow menu and no delete |
|
||||
| /schema/events (edit) | the overflow menu |
|
||||
|
||||
Scenario Outline: The menu is disabled while another action runs
|
||||
Given /schema/recipes and <state>
|
||||
Then the "More actions" button is disabled
|
||||
|
||||
Examples:
|
||||
| state |
|
||||
| the PUT is in flight (Saving, Applying) |
|
||||
| a delete is in flight |
|
||||
|
||||
Scenario: Blocked, the type has entries (design group 3, state 5)
|
||||
Given /schema/events has 37 entries
|
||||
When the user chooses "Delete content type"
|
||||
Then no request is sent
|
||||
And the dialog "You can't delete Events yet" opens with "A content type can only be deleted when it has no entries."
|
||||
And a warning alert reads "Events has 37 entries" with "Delete all of its entries first, then delete the content type."
|
||||
And the actions are "Close" and "View entries"; there is no delete button
|
||||
And the count is grouped like the info bar ("1,200 entries") and a single entry reads "Recipes has 1 entry" (proposed copy)
|
||||
|
||||
Scenario: View entries
|
||||
Given the blocked dialog or the fallback dialog is open
|
||||
When the user activates "View entries"
|
||||
Then the dialog closes and the router navigates to /content/events (see open question 4)
|
||||
|
||||
Scenario: Close the blocked dialog
|
||||
Given the blocked dialog is open
|
||||
When the user chooses "Close", the close icon or presses Esc
|
||||
Then the dialog closes, the page and every edit are unchanged and focus returns to the menu button
|
||||
|
||||
Scenario: Confirm, the type has no entries (design group 3, state 6)
|
||||
Given /schema/recipes has 0 entries
|
||||
When the user chooses "Delete content type"
|
||||
Then no request is sent
|
||||
And the dialog "Delete Recipes?" opens with "Recipes has no entries. Its fields and its API routes will be deleted. This can't be undone."
|
||||
And the body reads "Key: " then "recipes" in mono
|
||||
And the actions are "Cancel" and "Delete content type" (error colour)
|
||||
And Cancel, the close icon and Esc close it with nothing sent and focus returned to the menu button
|
||||
|
||||
Scenario: Deleting (proposed, see open question 3)
|
||||
Given the confirm dialog is open
|
||||
When the user activates "Delete content type"
|
||||
Then DELETE /v1/content-type/recipes is sent once
|
||||
And the button reads "Deleting…" with a loader, Cancel is disabled
|
||||
And Esc, the close icon and a click on the overlay do not dismiss the dialog
|
||||
And a second activation sends nothing
|
||||
|
||||
Scenario: Deleted
|
||||
Given DELETE answers 204
|
||||
Then the dialog closes
|
||||
And the content types store drops "recipes" (store.drop) with no GET /v1/content-type
|
||||
And the router navigates to /schema with no "Leave without saving?" prompt
|
||||
And a success toast "Content type deleted" with "Recipes was deleted." (proposed copy) is shown
|
||||
And the Schema list no longer shows Recipes and the sidebar Content group no longer lists it
|
||||
And no request to GET /v1/content-type/recipes/... is made afterwards
|
||||
|
||||
Scenario: Back or reload after a delete
|
||||
Given Recipes was deleted
|
||||
When the user opens /schema/recipes
|
||||
Then the page "Content type not found" with "It may have been deleted, or the address may be mistyped." is shown
|
||||
|
||||
Scenario: Server fallback, entries appeared (design group 3, state 7)
|
||||
Given the page loaded Recipes with 0 entries
|
||||
When the user confirms and DELETE answers 409 content_type_has_entries
|
||||
Then the dialog stays open with the title "Delete Recipes?" and the error alert "Recipes now has entries" with "Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again."
|
||||
And the actions are "Close" and "View entries"; there is no delete button
|
||||
And the store still holds Recipes and the user stays on /schema/recipes
|
||||
And "Close" closes the dialog; the loaded count is not refreshed, so choosing "Delete content type" again opens the confirm dialog (see open question 2)
|
||||
|
||||
Scenario Outline: Other answers of DELETE
|
||||
Given the confirm dialog is open and DELETE <outcome>
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| outcome | result |
|
||||
| answers 404 content_type_not_found | the dialog closes, the store drops the type and the page "Content type not found" is shown (proposed, see open question 5) |
|
||||
| answers 403 "Only ..." | the dialog closes and the page "You don't have access to Schema" is shown |
|
||||
| answers 401 | the existing redirect to /login with the expired reason |
|
||||
| answers 500 or fails (network) | the dialog stays open with the error alert "Couldn't delete the content type" (proposed copy) and "Something went wrong on our side. Nothing was changed. Try again in a moment."; "Cancel" and "Delete content type" are enabled again and the store is untouched |
|
||||
|
||||
Scenario: Retry after a failure
|
||||
Given the dialog shows the 500 alert
|
||||
When the user activates "Delete content type"
|
||||
Then the same DELETE is sent again and the alert clears while it is in flight
|
||||
|
||||
Scenario: Offline
|
||||
Given the page is offline (the existing offline banner is shown)
|
||||
When the confirm dialog is open
|
||||
Then "Delete content type" is disabled and nothing is sent
|
||||
And when the connection returns it is enabled again and nothing is sent by itself
|
||||
|
||||
Scenario Outline: Unsaved edits and the delete (the leave guard)
|
||||
Given /schema/<key> with unsaved edits (chip "Unsaved changes")
|
||||
When the user chooses "Delete content type"
|
||||
Then the dialog is the one of the saved type's count, its copy never mentions the edits
|
||||
And opening it does not discard, validate or save the edits
|
||||
And <after>
|
||||
|
||||
Examples:
|
||||
| key | after |
|
||||
| recipes | on Cancel, Close, 409 and 500, the edits, the chip and the undo rows are exactly as they were |
|
||||
| recipes | on 204 the router leaves with no "Leave without saving?" prompt and the edits are discarded with the page |
|
||||
| events | the blocked dialog is shown for the saved count (37), and Close keeps every edit |
|
||||
|
||||
Scenario: Delete after a refused save
|
||||
Given the banner "Nothing was changed" is shown for Recipes
|
||||
When the user deletes the type with no entries
|
||||
Then the delete flow is the same and the banner goes with the page
|
||||
|
||||
Scenario: Session and role
|
||||
Given a user who is neither owner nor admin opens /schema/recipes
|
||||
Then the page "You don't have access to Schema" is shown and no menu exists
|
||||
And the API still answers 403 to DELETE for that user
|
||||
|
||||
Scenario: Accessibility of the delete flow
|
||||
Then the menu button has an accessible name and the menu is keyboard operable
|
||||
And dialogs trap focus, open with focus on the title and return it to "More actions" on close
|
||||
And while Deleting, focus stays on the dialog title
|
||||
And the destructive button is never the default focus (focus starts on Cancel or Close)
|
||||
And controls are 44 px high on mobile and focus is a 2 px ring in the primary colour
|
||||
|
||||
Scenario: Key error on a new field in the edit builder (design state 7, visual only)
|
||||
Given /schema/events and a field added with the label "Second date", type Date and the key "2nd-date"
|
||||
Then the board shows 8 fields, that row selected with the badge "New", the Key "2nd-date" with "Start with a letter. Use lowercase letters, digits and underscores." and the alert icon, and the chip "1 problem to fix"
|
||||
And Save is disabled
|
||||
And behaviour is as in schema_builder_edit.feature "A single field key error on a new field"
|
||||
@@ -0,0 +1,42 @@
|
||||
# QA: Schema builder, delete (SPA slice 5)
|
||||
|
||||
Runner: Vitest + Vue Test Utils (`task test:web`). No Go test: DELETE /v1/content-type/{key} is already covered by features/custom_content_types.qa.md and the backend does not change.
|
||||
Same kit as features/schema_builder_edit.qa.md (`views/__tests__/schemaTestKit.ts`: add a DELETE handler per status, plain-text bodies content_type_has_entries and content_type_not_found; Events with total 37, Recipes with 4 fields and total 0). Never freeze the system date.
|
||||
|
||||
## Automated
|
||||
|
||||
1. Composable (useContentTypes.spec): `deleteContentType(key)` sends DELETE to /v1/content-type/{key}, resolves on 204 with no body parsing, ApiError carries status and plain text for 404, 409, 403, 500; NetworkError passes through.
|
||||
2. Store: `remove(key)` drops the custom type, keeps order of the rest, never touches built-ins, makes no request, does nothing for an unknown key. reset() unchanged.
|
||||
3. Menu: shown in edit mode only (not /schema/new, not built-in view, not for a non-owner/admin); one item "Delete content type"; Esc closes and returns focus; disabled while Saving, Applying and Deleting.
|
||||
4. Blocked (total 37): no request; copy, warning alert, "Close" and "View entries", no delete button; singular and 1,200 grouping (proposed copy); Close/Esc/close icon keep edits; View entries navigates to /content/events.
|
||||
5. Confirm (total 0): no request; copy, "Key: recipes" in mono; Cancel/Esc/close icon send nothing; focus starts on Cancel.
|
||||
6. Deleting: one DELETE, "Deleting…" with loader, Cancel disabled, Esc/overlay/close icon do not dismiss, second activation sends nothing, focus on the title.
|
||||
7. Deleted: 204 closes the dialog, store.drop called, navigates to /schema with no leave prompt even with unsaved edits, toast copy, list and sidebar Content group no longer show the type, no GET /v1/content-type afterwards; reopening /schema/recipes shows "Content type not found".
|
||||
8. Fallback 409: dialog keeps title "Delete Recipes?", error alert copy, "Close" and "View entries", no delete button, store untouched, user stays on the page, edits kept.
|
||||
9. Other answers (it.each): 404 (dialog closes, store drops, not-found page), 403 (access page), 401 (redirect to /login with expired reason), 500 and network (alert copy in the dialog, store untouched, retry sends the same DELETE and clears the alert).
|
||||
10. Offline: Delete disabled with the dialog open, enabled on reconnect, nothing sent by itself.
|
||||
11. Unsaved edits: dialog uses the saved count; opening, Cancel, Close and failures leave edits, chip and undo rows unchanged; 204 leaves with no prompt; blocked dialog for Events keeps edits.
|
||||
12. Accessibility: focus return to "More actions", destructive button not default focus, menu keyboard use.
|
||||
13. Structural refactor (SchemaTypeView.vue, six flags simplified): no new behaviour. Every existing SchemaTypeView/SchemaBuilder spec, schema_builtin_view, create and edit specs pass unchanged with no edit to their assertions; if one needs a change, stop and report. The resolver outcomes (built-in, custom edit, create, not found, forbidden, loading, error) are covered by one it.each over the former flag combinations.
|
||||
14. French: every new string has an entry (i18n completeness spec passes).
|
||||
|
||||
## Visual (task visual:check -- schema)
|
||||
|
||||
Every capture within 10 %, desktop and mobile, light and dark. Register nothing new in `extraSources` (group "3 Review and delete" is already registered).
|
||||
|
||||
| Board state | Setup in web/visual/states.ts | Board sample data |
|
||||
|---|---|---|
|
||||
| Delete type: blocked | path /schema/events, steps: open "More actions", choose "Delete content type" | "You can't delete Events yet", "Events has 37 entries" |
|
||||
| Delete type: confirm | path /schema/recipes (Recipes 4 fields, entry total 0), same steps | "Delete Recipes?", "Key: recipes" |
|
||||
| Delete type: server fallback (409) | path /schema/recipes, DELETE mocked 409 content_type_has_entries, steps: open menu, choose item, "Delete content type" | "Recipes now has entries" |
|
||||
| Field key error | replace `skip: 'pending'`; path /schema/events (the board draws it in the edit builder, not /schema/new); steps: Add field, label "Second date", type Date, set key "2nd-date" | 8 fields, row selected, chip "1 problem to fix" |
|
||||
|
||||
Board caveat (report, never hide): in the three delete frames the page behind the dialog is the Events builder while the dialog says Recipes (the frame reuses one builder). The overlay is compared as drawn (see open question 6). The desktop overflow menu itself is not drawn on any board: it is not visually checked.
|
||||
|
||||
## Manual
|
||||
|
||||
- Real backend, owner and admin: create a type with entries and try to delete it (dialog blocked, no request). Delete the entries, delete the type (204), check the sidebar and the list, reload /schema/<key>.
|
||||
- Race: open Recipes, add an entry elsewhere, confirm the delete and check the real 409 fallback.
|
||||
- Delete a type while another tab shows it, then act in the other tab (404 page).
|
||||
- Keyboard: Tab to "More actions", open with Enter, dialogs trap focus and return it. Screen reader announces the dialog titles and the error alert.
|
||||
- Real offline: open the confirm dialog, go offline, reconnect.
|
||||
@@ -40,5 +40,6 @@ defineEmits<{ save: []; discard: [] }>()
|
||||
>
|
||||
{{ saving ? t('Saving…') : t('Save') }}
|
||||
</UButton>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
defineProps<{ disabled: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ delete: [] }>()
|
||||
|
||||
const trigger = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
const items = computed(() => [
|
||||
[
|
||||
{
|
||||
label: t('Delete content type'),
|
||||
icon: 'i-lucide-trash-2',
|
||||
color: 'error' as const,
|
||||
onSelect: () => emit('delete'),
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
function focus() {
|
||||
trigger.value?.$el.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDropdownMenu
|
||||
:items="items"
|
||||
:content="{ align: 'end' }"
|
||||
:ui="{
|
||||
content: 'w-55 p-1',
|
||||
group: 'p-0',
|
||||
item: 'h-8 gap-2 px-2 text-sm max-md:h-11',
|
||||
itemLeadingIcon: 'size-4',
|
||||
}"
|
||||
>
|
||||
<UButton
|
||||
ref="trigger"
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('More actions')"
|
||||
class="size-8 shrink-0 justify-center text-muted data-[state=open]:outline-2 data-[state=open]:outline-offset-2 data-[state=open]:outline-primary max-md:size-11"
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import { entriesCountLine } from '@/lib/schemaEntries'
|
||||
import { isConfirmStage, type DeleteStage } from '@/lib/schemaDeleteStage'
|
||||
|
||||
const props = defineProps<{
|
||||
stage: Exclude<DeleteStage, 'closed'>
|
||||
name: string
|
||||
keyValue: string
|
||||
total: number
|
||||
pending: boolean
|
||||
offline: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ confirm: []; close: []; viewEntries: [] }>()
|
||||
|
||||
const blocked = computed(() => props.stage === 'blocked')
|
||||
const conflict = computed(() => props.stage === 'conflict')
|
||||
const confirming = computed(() => isConfirmStage(props.stage))
|
||||
const informational = computed(() => !confirming.value)
|
||||
|
||||
const title = computed(() =>
|
||||
blocked.value ? t("You can't delete {0} yet", [props.name]) : t('Delete {0}?', [props.name]),
|
||||
)
|
||||
const description = computed(() => {
|
||||
if (blocked.value) {
|
||||
return t('A content type can only be deleted when it has no entries.')
|
||||
}
|
||||
return t(
|
||||
"{0} has no entries. Its fields and its API routes will be deleted. This can't be undone.",
|
||||
[props.name],
|
||||
)
|
||||
})
|
||||
const notice = computed(() => {
|
||||
if (conflict.value) {
|
||||
return {
|
||||
title: t('{0} now has entries', [props.name]),
|
||||
description: t(
|
||||
'Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again.',
|
||||
),
|
||||
}
|
||||
}
|
||||
if (props.stage === 'failed') {
|
||||
return {
|
||||
title: t("Couldn't delete the content type"),
|
||||
description: t(
|
||||
'Something went wrong on our side. Nothing was changed. Try again in a moment.',
|
||||
),
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function onConfirm() {
|
||||
if (informational.value) {
|
||||
emit('viewEntries')
|
||||
} else {
|
||||
emit('confirm')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
:title="title"
|
||||
:description="description"
|
||||
:confirm-label="informational ? t('View entries') : t('Delete content type')"
|
||||
:pending-label="t('Deleting…')"
|
||||
:cancel-label="informational ? t('Close') : t('Cancel')"
|
||||
:tone="informational ? 'neutral' : 'danger'"
|
||||
:pending="pending"
|
||||
:confirm-disabled="offline && !informational"
|
||||
:notice="notice"
|
||||
closable
|
||||
close-gutter
|
||||
@confirm="onConfirm"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<UAlert
|
||||
v-if="blocked"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:title="entriesCountLine(name, total)"
|
||||
:description="t('Delete all of its entries first, then delete the content type.')"
|
||||
:ui="{
|
||||
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||
title: 'font-semibold',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
/>
|
||||
<p v-else-if="confirming" class="text-toned">
|
||||
{{ t('Key') }}: <span class="font-mono text-xs">{{ keyValue }}</span>
|
||||
</p>
|
||||
</DirectoryDialog>
|
||||
</template>
|
||||
@@ -18,6 +18,7 @@ const props = withDefaults(
|
||||
closable?: boolean
|
||||
confirmDisabled?: boolean
|
||||
wide?: boolean
|
||||
closeGutter?: boolean
|
||||
}>(),
|
||||
{
|
||||
cancelLabel: '',
|
||||
@@ -27,6 +28,7 @@ const props = withDefaults(
|
||||
closable: false,
|
||||
confirmDisabled: false,
|
||||
wide: false,
|
||||
closeGutter: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -51,6 +53,15 @@ watch(
|
||||
)
|
||||
|
||||
const confirmText = computed(() => (props.pending ? props.pendingLabel : props.confirmLabel))
|
||||
const headerClass = computed(() => {
|
||||
if (props.wide && props.closable) {
|
||||
return 'min-h-0 px-6 pe-20 pt-5 pb-0 sm:px-6 sm:pe-16'
|
||||
}
|
||||
if (props.closeGutter && props.closable) {
|
||||
return 'min-h-0 px-6 pe-14 pt-5 pb-0 sm:px-6 sm:pe-10'
|
||||
}
|
||||
return 'min-h-0 px-6 pt-5 pb-0 sm:px-6'
|
||||
})
|
||||
const closeButton = computed(() =>
|
||||
props.closable && !props.pending
|
||||
? { class: 'size-7 justify-center text-muted max-md:size-11' }
|
||||
@@ -86,10 +97,7 @@ function onCancel() {
|
||||
}"
|
||||
:ui="{
|
||||
content: contentClass,
|
||||
header:
|
||||
props.wide && props.closable
|
||||
? 'min-h-0 px-6 pe-20 pt-5 pb-0 sm:px-6 sm:pe-16'
|
||||
: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
header: headerClass,
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: 'mt-1 text-sm text-muted text-pretty',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import ApiPreview from '@/components/ApiPreview.vue'
|
||||
@@ -9,7 +9,9 @@ import ApiPreviewSheet from '@/components/ApiPreviewSheet.vue'
|
||||
import BuilderActions from '@/components/BuilderActions.vue'
|
||||
import BuilderBadge from '@/components/BuilderBadge.vue'
|
||||
import BuilderInspector from '@/components/BuilderInspector.vue'
|
||||
import BuilderMoreMenu from '@/components/BuilderMoreMenu.vue'
|
||||
import BuilderTypeBar from '@/components/BuilderTypeBar.vue'
|
||||
import DeleteTypeDialog from '@/components/DeleteTypeDialog.vue'
|
||||
import FieldList from '@/components/FieldList.vue'
|
||||
import FieldSheet from '@/components/FieldSheet.vue'
|
||||
import RetryAlert from '@/components/RetryAlert.vue'
|
||||
@@ -17,10 +19,12 @@ import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import SchemaRefusalDialog from '@/components/SchemaRefusalDialog.vue'
|
||||
import SchemaReviewDialog from '@/components/SchemaReviewDialog.vue'
|
||||
import { useSchemaBuilder, type EditSource } from '@/composables/useSchemaBuilder'
|
||||
import { useSchemaDelete } from '@/composables/useSchemaDelete'
|
||||
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||
import { HOME_PATH } from '@/lib/routePaths'
|
||||
import { sampleJson } from '@/lib/schemaBuilderSample'
|
||||
import { entriesNotice } from '@/lib/schemaEntries'
|
||||
import { TOPBAR_ACTION_ID } from '@/lib/uiSlots'
|
||||
import { formatClock } from '@/lib/updateFormat'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
@@ -31,9 +35,15 @@ const router = useRouter()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const builder = useSchemaBuilder(props.edit)
|
||||
const { form } = builder
|
||||
const remove = props.edit ? useSchemaDelete(() => props.edit!) : undefined
|
||||
const moreMenu = ref<InstanceType<typeof BuilderMoreMenu> | null>(null)
|
||||
const moreDisabled = computed(() => builder.saving.value || Boolean(remove?.pending.value))
|
||||
const forbidden = computed(
|
||||
() => builder.failure.value === 'forbidden' || Boolean(remove?.forbidden.value),
|
||||
)
|
||||
|
||||
useUnsavedChangesGuard({
|
||||
isDirty: () => builder.dirty.value,
|
||||
isDirty: () => builder.dirty.value && !remove?.done.value,
|
||||
title: () => form.value.name.trim() || t('New content type'),
|
||||
})
|
||||
|
||||
@@ -63,6 +73,16 @@ const selectedName = computed(() => builder.selected.value?.label.trim() || t('U
|
||||
const sheetTitle = computed(() => t('Field: {0}', [selectedName.value]))
|
||||
const reviewName = computed(() => builder.baseline.value?.name ?? '')
|
||||
|
||||
watch(
|
||||
() => remove?.stage.value,
|
||||
async (stage) => {
|
||||
if (stage === 'closed') {
|
||||
await nextTick()
|
||||
moreMenu.value?.focus()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function goHome() {
|
||||
router.push(HOME_PATH)
|
||||
}
|
||||
@@ -106,7 +126,7 @@ async function restoreRowFocus() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SchemaForbidden v-if="builder.failure.value === 'forbidden'" @back="goHome" />
|
||||
<SchemaForbidden v-if="forbidden" @back="goHome" />
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
@@ -139,7 +159,14 @@ async function restoreRowFocus() {
|
||||
:saved-at="savedClock"
|
||||
@save="builder.save"
|
||||
@discard="builder.discard"
|
||||
/>
|
||||
>
|
||||
<BuilderMoreMenu
|
||||
v-if="remove"
|
||||
ref="moreMenu"
|
||||
:disabled="moreDisabled"
|
||||
@delete="remove.open"
|
||||
/>
|
||||
</BuilderActions>
|
||||
</template>
|
||||
</BuilderTypeBar>
|
||||
</div>
|
||||
@@ -284,6 +311,9 @@ async function restoreRowFocus() {
|
||||
</div>
|
||||
|
||||
<template v-if="!isDesktop">
|
||||
<Teleport v-if="remove" :to="`#${TOPBAR_ACTION_ID}`" defer>
|
||||
<BuilderMoreMenu ref="moreMenu" :disabled="moreDisabled" @delete="remove.open" />
|
||||
</Teleport>
|
||||
<ApiPreviewSheet v-model:open="apiSheetOpen" :key-value="form.key" :json="json" />
|
||||
<FieldSheet
|
||||
v-model:open="fieldSheetOpen"
|
||||
@@ -316,6 +346,18 @@ async function restoreRowFocus() {
|
||||
@apply="builder.apply"
|
||||
@close="builder.closeReview"
|
||||
/>
|
||||
<DeleteTypeDialog
|
||||
v-if="remove && remove.stage.value !== 'closed'"
|
||||
:stage="remove.stage.value"
|
||||
:name="remove.type.value.name"
|
||||
:key-value="remove.type.value.key"
|
||||
:total="remove.total.value"
|
||||
:pending="remove.pending.value"
|
||||
:offline="remove.offline.value"
|
||||
@confirm="remove.confirm"
|
||||
@close="remove.close"
|
||||
@view-entries="remove.viewEntries"
|
||||
/>
|
||||
<SchemaRefusalDialog
|
||||
v-if="builder.refusal.value === 'dialog'"
|
||||
:causes="refusalCauses"
|
||||
|
||||
@@ -205,6 +205,43 @@ describe('updateContentType', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteContentType', () => {
|
||||
it('DELETEs the encoded key and resolves on 204', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 204 }))
|
||||
|
||||
await expect(useContentTypes().deleteContentType('a/b')).resolves.toBeUndefined()
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]!
|
||||
expect(url).toBe('/v1/content-type/a%2Fb')
|
||||
expect(init.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[404, 'content_type_not_found'],
|
||||
[409, 'content_type_has_entries'],
|
||||
[403, 'Only owners and admins can do this'],
|
||||
[500, 'boom'],
|
||||
])('rejects %i with its plain-text body', async (status, text) => {
|
||||
answer(status, text)
|
||||
|
||||
const failure = await useContentTypes()
|
||||
.deleteContentType('recipes')
|
||||
.catch((error) => error)
|
||||
|
||||
expect(failure).toBeInstanceOf(ApiError)
|
||||
expect(failure.status).toBe(status)
|
||||
expect(failure.body).toBe(text)
|
||||
})
|
||||
|
||||
it('lets a network failure through', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
|
||||
await expect(useContentTypes().deleteContentType('recipes')).rejects.toBeInstanceOf(
|
||||
NetworkError,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('countEntries', () => {
|
||||
it('reads the total of a five-entry page', async () => {
|
||||
answer(200, { items: [], total: 37, page: 1, size: 5 })
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent, shallowRef } from 'vue'
|
||||
import router from '@/router'
|
||||
import { useSchemaDelete } from '@/composables/useSchemaDelete'
|
||||
import type { EditSource } from '@/composables/useSchemaBuilder'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
|
||||
const recipes = {
|
||||
id: 2,
|
||||
key: 'recipes',
|
||||
name: 'Recipes',
|
||||
icon: null,
|
||||
fields: [],
|
||||
created_at: '2026-09-01T10:00:00Z',
|
||||
updated_at: '2026-10-02T10:00:00Z',
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
const source = shallowRef<EditSource>({ type: recipes, total: 0 })
|
||||
let wrapper: VueWrapper | undefined
|
||||
|
||||
function deleteCalls() {
|
||||
return fetchMock.mock.calls.filter(([, init]) => init?.method === 'DELETE')
|
||||
}
|
||||
|
||||
function setup() {
|
||||
let api!: ReturnType<typeof useSchemaDelete>
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
api = useSchemaDelete(() => source.value)
|
||||
return () => null
|
||||
},
|
||||
}),
|
||||
{ global: { plugins: [router] } },
|
||||
)
|
||||
return api
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
source.value = { type: recipes, total: 0 }
|
||||
fetchMock.mockReset()
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 204 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('useSchemaDelete', () => {
|
||||
it('opens the blocked stage with entries and the confirm stage without', () => {
|
||||
const api = setup()
|
||||
|
||||
api.open()
|
||||
expect(api.stage.value).toBe('confirm')
|
||||
|
||||
source.value = { type: recipes, total: 3 }
|
||||
api.open()
|
||||
expect(api.stage.value).toBe('blocked')
|
||||
})
|
||||
|
||||
it('reads the source lazily so a renamed type is the one it acts on', async () => {
|
||||
const api = setup()
|
||||
api.open()
|
||||
source.value = { type: { ...recipes, name: 'Recipes box' }, total: 0 }
|
||||
|
||||
expect(api.type.value.name).toBe('Recipes box')
|
||||
})
|
||||
|
||||
it('keeps the leave guard off after a successful delete', async () => {
|
||||
vi.spyOn(router, 'push').mockResolvedValue(undefined)
|
||||
const api = setup()
|
||||
api.open()
|
||||
|
||||
await api.confirm()
|
||||
|
||||
expect(api.done.value).toBe(true)
|
||||
})
|
||||
|
||||
it('puts the leave guard back when the navigation is rejected', async () => {
|
||||
vi.spyOn(router, 'push').mockRejectedValue(new Error('boom'))
|
||||
const api = setup()
|
||||
api.open()
|
||||
|
||||
await api.confirm()
|
||||
await flushPromises()
|
||||
|
||||
expect(api.done.value).toBe(false)
|
||||
expect(api.stage.value).toBe('closed')
|
||||
})
|
||||
|
||||
it('puts the leave guard back when the navigation fails', async () => {
|
||||
vi.spyOn(router, 'push').mockResolvedValue({ type: 4 } as never)
|
||||
const api = setup()
|
||||
api.open()
|
||||
|
||||
await api.confirm()
|
||||
|
||||
expect(api.done.value).toBe(false)
|
||||
})
|
||||
|
||||
it('still drops the deleted type from the store when the navigation fails', async () => {
|
||||
vi.spyOn(router, 'push').mockRejectedValue(new Error('boom'))
|
||||
const store = useContentTypesStore()
|
||||
const drop = vi.spyOn(store, 'drop')
|
||||
const api = setup()
|
||||
api.open()
|
||||
|
||||
await api.confirm()
|
||||
|
||||
expect(drop).toHaveBeenCalledWith('recipes')
|
||||
})
|
||||
|
||||
it('goes to the failed stage on a server error and back to confirm on retry', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('{}', { status: 500 }))
|
||||
vi.spyOn(router, 'push').mockResolvedValue(undefined)
|
||||
const api = setup()
|
||||
api.open()
|
||||
|
||||
await api.confirm()
|
||||
expect(api.stage.value).toBe('failed')
|
||||
|
||||
const retry = api.confirm()
|
||||
expect(api.stage.value).toBe('confirm')
|
||||
await retry
|
||||
expect(api.done.value).toBe(true)
|
||||
})
|
||||
|
||||
it('sends a single DELETE when confirm is called again while one is in flight', async () => {
|
||||
let release!: (response: Response) => void
|
||||
fetchMock.mockReturnValueOnce(new Promise<Response>((done) => (release = done)))
|
||||
vi.spyOn(router, 'push').mockResolvedValue(undefined)
|
||||
const api = setup()
|
||||
api.open()
|
||||
|
||||
const first = api.confirm()
|
||||
await api.confirm()
|
||||
release(new Response(null, { status: 204 }))
|
||||
await first
|
||||
|
||||
expect(deleteCalls()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sends nothing when confirm is called offline', async () => {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
const api = setup()
|
||||
api.open()
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
await flushPromises()
|
||||
|
||||
await api.confirm()
|
||||
|
||||
expect(api.offline.value).toBe(true)
|
||||
expect(deleteCalls()).toHaveLength(0)
|
||||
expect(api.stage.value).toBe('confirm')
|
||||
})
|
||||
|
||||
it('does not close while a delete is in flight', async () => {
|
||||
let release!: (response: Response) => void
|
||||
fetchMock.mockReturnValueOnce(new Promise<Response>((done) => (release = done)))
|
||||
vi.spyOn(router, 'push').mockResolvedValue(undefined)
|
||||
const api = setup()
|
||||
api.open()
|
||||
const running = api.confirm()
|
||||
|
||||
api.close()
|
||||
expect(api.stage.value).toBe('confirm')
|
||||
expect(api.pending.value).toBe(true)
|
||||
|
||||
release(new Response(null, { status: 204 }))
|
||||
await running
|
||||
})
|
||||
|
||||
it('closes when nothing is in flight', () => {
|
||||
const api = setup()
|
||||
api.open()
|
||||
|
||||
api.close()
|
||||
|
||||
expect(api.stage.value).toBe('closed')
|
||||
})
|
||||
|
||||
it('goes to the conflict stage on a 409 about entries', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('content_type_has_entries', { status: 409 }))
|
||||
const api = setup()
|
||||
api.open()
|
||||
|
||||
await api.confirm()
|
||||
|
||||
expect(api.stage.value).toBe('conflict')
|
||||
})
|
||||
|
||||
it('treats a 409 with any other body as a plain failure', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('something_else', { status: 409 }))
|
||||
const api = setup()
|
||||
api.open()
|
||||
|
||||
await api.confirm()
|
||||
|
||||
expect(api.stage.value).toBe('failed')
|
||||
})
|
||||
|
||||
it('stays silent on a 401 and leaves the stage as it was', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('{}', { status: 401 }))
|
||||
const api = setup()
|
||||
api.open()
|
||||
|
||||
await api.confirm()
|
||||
|
||||
expect(api.stage.value).toBe('confirm')
|
||||
expect(api.pending.value).toBe(false)
|
||||
expect(api.done.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -64,6 +64,13 @@ export function useContentTypes() {
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteContentType(key: string): Promise<void> {
|
||||
await request<void>(`/v1/content-type/${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
async function countEntries(key: string): Promise<number> {
|
||||
const page = await request<{ total?: unknown }>(
|
||||
`/v1/content-type/${encodeURIComponent(key)}/entry?page=1&size=5`,
|
||||
@@ -80,6 +87,7 @@ export function useContentTypes() {
|
||||
listBuiltinTypes,
|
||||
createContentType,
|
||||
updateContentType,
|
||||
deleteContentType,
|
||||
countEntries,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useContentTypes, type ContentType } from '@/composables/useContentTypes'
|
||||
import type { EditSource } from '@/composables/useSchemaBuilder'
|
||||
import { contentLink } from '@/lib/contentNav'
|
||||
import { isConfirmStage, type DeleteStage } from '@/lib/schemaDeleteStage'
|
||||
import { SCHEMA_PATH } from '@/lib/routePaths'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
|
||||
const HAS_ENTRIES_BODY = 'content_type_has_entries'
|
||||
const TOAST_ID = 'content-type-deleted'
|
||||
|
||||
export function useSchemaDelete(source: () => EditSource) {
|
||||
const router = useRouter()
|
||||
const store = useContentTypesStore()
|
||||
const toast = useLocalizedToast()
|
||||
const online = useOnline()
|
||||
|
||||
const stage = ref<DeleteStage>('closed')
|
||||
const pending = ref(false)
|
||||
const forbidden = ref(false)
|
||||
const done = ref(false)
|
||||
const offline = computed(() => !online.value)
|
||||
const type = computed(() => source().type)
|
||||
const total = computed(() => source().total)
|
||||
|
||||
function open() {
|
||||
if (pending.value) {
|
||||
return
|
||||
}
|
||||
stage.value = total.value > 0 ? 'blocked' : 'confirm'
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!pending.value) {
|
||||
stage.value = 'closed'
|
||||
}
|
||||
}
|
||||
|
||||
async function viewEntries() {
|
||||
stage.value = 'closed'
|
||||
await router.push(contentLink(type.value.key)).catch(() => undefined)
|
||||
}
|
||||
|
||||
function announceDeleted(name: string) {
|
||||
toast.add(() => ({
|
||||
id: TOAST_ID,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: t('Content type deleted'),
|
||||
description: t('{0} was deleted.', [name]),
|
||||
}))
|
||||
}
|
||||
|
||||
async function leave(deleted: ContentType) {
|
||||
done.value = true
|
||||
stage.value = 'closed'
|
||||
announceDeleted(deleted.name)
|
||||
const arrived = await router.push(SCHEMA_PATH).then(
|
||||
(failure) => !failure,
|
||||
() => false,
|
||||
)
|
||||
if (!arrived) {
|
||||
done.value = false
|
||||
}
|
||||
store.drop(deleted.key)
|
||||
}
|
||||
|
||||
function refuse(error: unknown, key: string) {
|
||||
const status = error instanceof ApiError ? error.status : 0
|
||||
const body = error instanceof ApiError ? error.body : undefined
|
||||
if (status === 401) {
|
||||
return
|
||||
}
|
||||
if (status === 404) {
|
||||
stage.value = 'closed'
|
||||
store.drop(key)
|
||||
} else if (status === 403) {
|
||||
stage.value = 'closed'
|
||||
forbidden.value = true
|
||||
} else if (status === 409 && body === HAS_ENTRIES_BODY) {
|
||||
stage.value = 'conflict'
|
||||
} else {
|
||||
stage.value = 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (pending.value || offline.value || !isConfirmStage(stage.value)) {
|
||||
return
|
||||
}
|
||||
const deleted = type.value
|
||||
pending.value = true
|
||||
stage.value = 'confirm'
|
||||
try {
|
||||
await useContentTypes().deleteContentType(deleted.key)
|
||||
} catch (error) {
|
||||
pending.value = false
|
||||
refuse(error, deleted.key)
|
||||
return
|
||||
}
|
||||
pending.value = false
|
||||
await leave(deleted)
|
||||
}
|
||||
|
||||
return {
|
||||
stage,
|
||||
pending,
|
||||
forbidden,
|
||||
done,
|
||||
offline,
|
||||
type,
|
||||
total,
|
||||
open,
|
||||
close,
|
||||
viewEntries,
|
||||
confirm,
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ const SOURCES = [
|
||||
'components/AuthorFormPanel.vue',
|
||||
'components/AuthorViewPanel.vue',
|
||||
'components/DashboardShell.vue',
|
||||
'components/BuilderMoreMenu.vue',
|
||||
'components/DeleteTypeDialog.vue',
|
||||
'components/DirectoryBadge.vue',
|
||||
'components/DirectoryDialog.vue',
|
||||
'components/DirectoryField.vue',
|
||||
@@ -26,10 +28,12 @@ const SOURCES = [
|
||||
'components/SocialLinksRepeater.vue',
|
||||
'components/UserSelect.vue',
|
||||
'composables/useDirectoryToasts.ts',
|
||||
'composables/useSchemaDelete.ts',
|
||||
'composables/useDirectoryView.ts',
|
||||
'lib/directory.ts',
|
||||
'lib/directoryCopy.ts',
|
||||
'lib/directoryRules.ts',
|
||||
'lib/schemaEntries.ts',
|
||||
'lib/siteTitleTemplate.ts',
|
||||
'views/AuthorsView.vue',
|
||||
'views/SitesView.vue',
|
||||
|
||||
@@ -2486,6 +2486,23 @@ export const messages = {
|
||||
'Values must now be unique.': 'Les valeurs doivent maintenant être uniques.',
|
||||
'Values no longer need to be unique.': 'Les valeurs n’ont plus besoin d’être uniques.',
|
||||
'Your content type is as it was': 'Votre type de contenu est tel qu’il était',
|
||||
'Delete content type': 'Supprimer le type de contenu',
|
||||
"You can't delete {0} yet": 'Vous ne pouvez pas encore supprimer {0}',
|
||||
'A content type can only be deleted when it has no entries.':
|
||||
'Un type de contenu ne peut être supprimé que s’il n’a aucune entrée.',
|
||||
'Delete all of its entries first, then delete the content type.':
|
||||
'Supprimez d’abord toutes ses entrées, puis supprimez le type de contenu.',
|
||||
"{0} has no entries. Its fields and its API routes will be deleted. This can't be undone.":
|
||||
'{0} n’a aucune entrée. Ses champs et ses routes d’API seront supprimés. Cette action est irréversible.',
|
||||
'{0} has 1 entry': '{0} a 1 entrée',
|
||||
'{0} has {1} entries': '{0} a {1} entrées',
|
||||
'{0} now has entries': '{0} a maintenant des entrées',
|
||||
'Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again.':
|
||||
'Quelqu’un a ajouté une entrée après le chargement de cette page, donc rien n’a été supprimé. Supprimez d’abord les entrées, puis réessayez.',
|
||||
"Couldn't delete the content type": 'Impossible de supprimer le type de contenu',
|
||||
'View entries': 'Voir les entrées',
|
||||
'Content type deleted': 'Type de contenu supprimé',
|
||||
'{0} was deleted.': '{0} a été supprimé.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveSchemaTypeView, type SchemaTypeInputs } from '@/lib/schemaTypeView'
|
||||
|
||||
const SETTLED: SchemaTypeInputs = {
|
||||
listsForbidden: false,
|
||||
listsLoading: false,
|
||||
customFailed: false,
|
||||
builtinFailed: false,
|
||||
hasBuiltin: false,
|
||||
hasCustom: false,
|
||||
skeletonVisible: false,
|
||||
countStatus: 'idle',
|
||||
}
|
||||
|
||||
const resolve = (patch: Partial<SchemaTypeInputs>) =>
|
||||
resolveSchemaTypeView({ ...SETTLED, ...patch })
|
||||
|
||||
describe('resolveSchemaTypeView', () => {
|
||||
it.each([
|
||||
['forbidden lists', { listsForbidden: true }],
|
||||
['forbidden count', { countStatus: 'forbidden' }],
|
||||
[
|
||||
'forbidden over loading',
|
||||
{ listsForbidden: true, listsLoading: true, skeletonVisible: true },
|
||||
],
|
||||
['forbidden over a custom type', { countStatus: 'forbidden', hasCustom: true }],
|
||||
] as const)('is forbidden and bleeds for %s', (_, patch) => {
|
||||
expect(resolve(patch)).toEqual({
|
||||
view: 'forbidden',
|
||||
editable: false,
|
||||
loading: false,
|
||||
bleed: true,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['lists loading', { listsLoading: true }],
|
||||
['count idle on a custom type', { hasCustom: true, countStatus: 'idle' }],
|
||||
['count loading on a custom type', { hasCustom: true, countStatus: 'loading' }],
|
||||
] as const)('shows the skeleton only once visible, %s', (_, patch) => {
|
||||
expect(resolve({ ...patch, skeletonVisible: true })).toEqual({
|
||||
view: 'skeleton',
|
||||
editable: false,
|
||||
loading: true,
|
||||
bleed: false,
|
||||
})
|
||||
expect(resolve({ ...patch, skeletonVisible: false })).toEqual({
|
||||
view: 'blank',
|
||||
editable: false,
|
||||
loading: true,
|
||||
bleed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not wait for the count when there is no custom type', () => {
|
||||
expect(resolve({ hasBuiltin: true, countStatus: 'loading' }).loading).toBe(false)
|
||||
})
|
||||
|
||||
it('reports a failed custom list before anything else', () => {
|
||||
expect(
|
||||
resolve({
|
||||
customFailed: true,
|
||||
builtinFailed: true,
|
||||
hasBuiltin: true,
|
||||
hasCustom: true,
|
||||
countStatus: 'failed',
|
||||
}),
|
||||
).toEqual({ view: 'listsFailed', editable: false, loading: false, bleed: false })
|
||||
})
|
||||
|
||||
it('reports a failed built-in list after the custom one', () => {
|
||||
expect(resolve({ builtinFailed: true, hasBuiltin: true, countStatus: 'failed' })).toEqual({
|
||||
view: 'builtinListFailed',
|
||||
editable: false,
|
||||
loading: false,
|
||||
bleed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a failed count on a custom type', () => {
|
||||
expect(resolve({ hasCustom: true, hasBuiltin: true, countStatus: 'failed' })).toEqual({
|
||||
view: 'countFailed',
|
||||
editable: false,
|
||||
loading: false,
|
||||
bleed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('is editable and bleeds for a custom type with a ready count', () => {
|
||||
expect(resolve({ hasCustom: true, hasBuiltin: true, countStatus: 'ready' })).toEqual({
|
||||
view: 'editable',
|
||||
editable: true,
|
||||
loading: false,
|
||||
bleed: true,
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['idle', 'ready', 'missing'] as const)(
|
||||
'shows a built-in type without bleeding, count %s',
|
||||
(countStatus) => {
|
||||
expect(resolve({ hasBuiltin: true, countStatus })).toEqual({
|
||||
view: 'builtin',
|
||||
editable: false,
|
||||
loading: false,
|
||||
bleed: false,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['idle', 'missing', 'ready'] as const)(
|
||||
'is not found and bleeds with no type at all, count %s',
|
||||
(countStatus) => {
|
||||
expect(resolve({ countStatus })).toEqual({
|
||||
view: 'notFound',
|
||||
editable: false,
|
||||
loading: false,
|
||||
bleed: true,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('is not found for a custom type whose count went missing', () => {
|
||||
expect(resolve({ hasCustom: true, countStatus: 'missing' }).view).toBe('notFound')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
export type DeleteStage = 'closed' | 'blocked' | 'confirm' | 'failed' | 'conflict'
|
||||
|
||||
export function isConfirmStage(stage: DeleteStage) {
|
||||
return stage === 'confirm' || stage === 'failed'
|
||||
}
|
||||
@@ -19,3 +19,9 @@ export function entriesLine(name: string, total: number) {
|
||||
formatNumber(total),
|
||||
])
|
||||
}
|
||||
|
||||
export function entriesCountLine(name: string, total: number) {
|
||||
return total === 1
|
||||
? t('{0} has 1 entry', [name])
|
||||
: t('{0} has {1} entries', [name, formatNumber(total)])
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { EntryCountStatus } from '@/composables/useEntryCount'
|
||||
|
||||
export type SchemaTypeViewName =
|
||||
| 'forbidden'
|
||||
| 'skeleton'
|
||||
| 'listsFailed'
|
||||
| 'builtinListFailed'
|
||||
| 'countFailed'
|
||||
| 'editable'
|
||||
| 'builtin'
|
||||
| 'notFound'
|
||||
| 'blank'
|
||||
|
||||
export interface SchemaTypeInputs {
|
||||
listsForbidden: boolean
|
||||
listsLoading: boolean
|
||||
customFailed: boolean
|
||||
builtinFailed: boolean
|
||||
hasBuiltin: boolean
|
||||
hasCustom: boolean
|
||||
countStatus: EntryCountStatus
|
||||
skeletonVisible: boolean
|
||||
}
|
||||
|
||||
export interface SchemaTypeState {
|
||||
view: SchemaTypeViewName
|
||||
editable: boolean
|
||||
loading: boolean
|
||||
bleed: boolean
|
||||
}
|
||||
|
||||
export function resolveSchemaTypeView(input: SchemaTypeInputs): SchemaTypeState {
|
||||
const forbidden = input.listsForbidden || input.countStatus === 'forbidden'
|
||||
const countPending =
|
||||
input.hasCustom && (input.countStatus === 'idle' || input.countStatus === 'loading')
|
||||
const loading = !forbidden && (input.listsLoading || countPending)
|
||||
const settled = !forbidden && !loading
|
||||
const editable = settled && input.hasCustom && input.countStatus === 'ready'
|
||||
const listsFailed = settled && input.customFailed
|
||||
const builtinListFailed = settled && !listsFailed && input.builtinFailed
|
||||
const countFailed = settled && input.countStatus === 'failed'
|
||||
const notFound =
|
||||
settled &&
|
||||
!listsFailed &&
|
||||
!builtinListFailed &&
|
||||
!countFailed &&
|
||||
!input.hasBuiltin &&
|
||||
!editable
|
||||
|
||||
return {
|
||||
view: pickView({
|
||||
forbidden,
|
||||
skeleton: loading && input.skeletonVisible,
|
||||
listsFailed,
|
||||
builtinListFailed,
|
||||
countFailed,
|
||||
editable,
|
||||
builtin: input.hasBuiltin,
|
||||
notFound,
|
||||
}),
|
||||
editable,
|
||||
loading,
|
||||
bleed: forbidden || notFound || editable,
|
||||
}
|
||||
}
|
||||
|
||||
interface ViewFlags {
|
||||
forbidden: boolean
|
||||
skeleton: boolean
|
||||
listsFailed: boolean
|
||||
builtinListFailed: boolean
|
||||
countFailed: boolean
|
||||
editable: boolean
|
||||
builtin: boolean
|
||||
notFound: boolean
|
||||
}
|
||||
|
||||
function pickView(flags: ViewFlags): SchemaTypeViewName {
|
||||
if (flags.forbidden) return 'forbidden'
|
||||
if (flags.skeleton) return 'skeleton'
|
||||
if (flags.listsFailed) return 'listsFailed'
|
||||
if (flags.builtinListFailed) return 'builtinListFailed'
|
||||
if (flags.countFailed) return 'countFailed'
|
||||
if (flags.editable) return 'editable'
|
||||
if (flags.builtin) return 'builtin'
|
||||
if (flags.notFound) return 'notFound'
|
||||
return 'blank'
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const TOPBAR_ACTION_ID = 'topbar-action'
|
||||
@@ -360,6 +360,31 @@ describe('content types store: replace, drop and reset', () => {
|
||||
expect(store.items.map((item) => item.key)).toEqual(['events', 'press-mentions'])
|
||||
})
|
||||
|
||||
it('keeps a type dropped while the list is still loading out of the in-flight list', async () => {
|
||||
const first = deferred()
|
||||
listContentTypes.mockReturnValueOnce(first.promise).mockResolvedValueOnce([events])
|
||||
const store = useContentTypesStore()
|
||||
|
||||
const loaded = store.load()
|
||||
store.drop('recipes')
|
||||
first.resolve([events, recipes])
|
||||
await loaded
|
||||
|
||||
expect(listContentTypes).toHaveBeenCalledTimes(2)
|
||||
expect(store.items.map((item) => item.key)).toEqual(['events'])
|
||||
})
|
||||
|
||||
it('does nothing when dropping before any load started', async () => {
|
||||
listContentTypes.mockResolvedValue([events, recipes])
|
||||
const store = useContentTypesStore()
|
||||
|
||||
store.drop('recipes')
|
||||
await store.load()
|
||||
|
||||
expect(listContentTypes).toHaveBeenCalledTimes(1)
|
||||
expect(store.items.map((item) => item.key)).toEqual(['events', 'recipes'])
|
||||
})
|
||||
|
||||
it('reset clears both lists so the next session loads them again', async () => {
|
||||
const store = await loadedStore()
|
||||
|
||||
|
||||
@@ -112,6 +112,10 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
|
||||
}
|
||||
|
||||
function drop(matches: (existing: T) => boolean) {
|
||||
if (status.value === 'loading') {
|
||||
stale = true
|
||||
return
|
||||
}
|
||||
if (status.value !== 'ready') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import SchemaTypeNotFound from '@/components/SchemaTypeNotFound.vue'
|
||||
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||
import { useEntryCount } from '@/composables/useEntryCount'
|
||||
import { useSchemaGate } from '@/composables/useSchemaGate'
|
||||
import { resolveSchemaTypeView } from '@/lib/schemaTypeView'
|
||||
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
|
||||
import { TOPBAR_ACTION_ID } from '@/lib/uiSlots'
|
||||
|
||||
const route = useRoute()
|
||||
const offlineMessage = computed(() =>
|
||||
@@ -45,35 +47,23 @@ const count = useEntryCount(() =>
|
||||
: null,
|
||||
)
|
||||
|
||||
const forbidden = computed(() => listsForbidden.value || count.status.value === 'forbidden')
|
||||
const countPending = computed(
|
||||
() =>
|
||||
customType.value !== undefined &&
|
||||
(count.status.value === 'idle' || count.status.value === 'loading'),
|
||||
const state = computed(() =>
|
||||
resolveSchemaTypeView({
|
||||
listsForbidden: listsForbidden.value,
|
||||
listsLoading: listsLoading.value,
|
||||
customFailed: customFailed.value,
|
||||
builtinFailed: builtinFailed.value,
|
||||
hasBuiltin: builtinType.value !== undefined,
|
||||
hasCustom: customType.value !== undefined,
|
||||
countStatus: count.status.value,
|
||||
skeletonVisible: skeleton.visible.value,
|
||||
}),
|
||||
)
|
||||
const loading = computed(() => !forbidden.value && (listsLoading.value || countPending.value))
|
||||
const listsFailed = computed(() => !forbidden.value && !loading.value && customFailed.value)
|
||||
const builtinListFailed = computed(
|
||||
() => !forbidden.value && !loading.value && !listsFailed.value && builtinFailed.value,
|
||||
)
|
||||
const countFailed = computed(
|
||||
() => !forbidden.value && !loading.value && count.status.value === 'failed',
|
||||
)
|
||||
const editable = computed(
|
||||
() => !forbidden.value && !loading.value && customType.value && count.status.value === 'ready',
|
||||
)
|
||||
const notFound = computed(
|
||||
() =>
|
||||
!forbidden.value &&
|
||||
!loading.value &&
|
||||
!listsFailed.value &&
|
||||
!builtinListFailed.value &&
|
||||
!countFailed.value &&
|
||||
!builtinType.value &&
|
||||
!editable.value,
|
||||
)
|
||||
const showSkeleton = computed(() => loading.value && skeleton.visible.value)
|
||||
const bleed = computed(() => forbidden.value || notFound.value || Boolean(editable.value))
|
||||
const view = computed(() => state.value.view)
|
||||
const editable = computed(() => state.value.editable)
|
||||
const loading = computed(() => state.value.loading)
|
||||
const bleed = computed(() => state.value.bleed)
|
||||
const notFound = computed(() => view.value === 'notFound')
|
||||
|
||||
const displayName = computed(() => customType.value?.name ?? builtinType.value?.name)
|
||||
const mobileTitle = computed(() => displayName.value ?? t('Schema'))
|
||||
@@ -116,6 +106,10 @@ function goHome() {
|
||||
:offline-icon="editable ? 'i-lucide-wifi-off' : undefined"
|
||||
:flush-offline-banner="Boolean(editable)"
|
||||
>
|
||||
<template #navbar-right>
|
||||
<span :id="TOPBAR_ACTION_ID" />
|
||||
</template>
|
||||
|
||||
<template #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="breadcrumb"
|
||||
@@ -125,13 +119,13 @@ function goHome() {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SchemaForbidden v-if="forbidden" @back="goHome" />
|
||||
<SchemaForbidden v-if="view === 'forbidden'" @back="goHome" />
|
||||
|
||||
<template v-else-if="sessionReady">
|
||||
<SchemaTable v-if="showSkeleton" :custom="[]" :builtin="[]" loading />
|
||||
<SchemaTable v-if="view === 'skeleton'" :custom="[]" :builtin="[]" loading />
|
||||
|
||||
<DirectoryLoadError
|
||||
v-else-if="listsFailed"
|
||||
v-else-if="view === 'listsFailed'"
|
||||
:title="t('Couldn\'t load content types')"
|
||||
:description="
|
||||
t('GET /v1/content-type failed. Check your connection and try again.')
|
||||
@@ -140,7 +134,7 @@ function goHome() {
|
||||
/>
|
||||
|
||||
<DirectoryLoadError
|
||||
v-else-if="builtinListFailed"
|
||||
v-else-if="view === 'builtinListFailed'"
|
||||
:title="t('Couldn\'t load content types')"
|
||||
:description="
|
||||
t('GET /v1/builtin-type failed. Check your connection and try again.')
|
||||
@@ -149,15 +143,15 @@ function goHome() {
|
||||
/>
|
||||
|
||||
<DirectoryLoadError
|
||||
v-else-if="countFailed"
|
||||
v-else-if="view === 'countFailed'"
|
||||
:title="t('Couldn\'t load content type')"
|
||||
:description="countLine"
|
||||
@retry="count.retry"
|
||||
/>
|
||||
|
||||
<SchemaBuilderPage v-else-if="editable" :key="key" :edit="edit" />
|
||||
<SchemaBuilderPage v-else-if="view === 'editable'" :key="key" :edit="edit" />
|
||||
|
||||
<div v-else-if="builtinType" class="flex flex-col gap-4">
|
||||
<div v-else-if="view === 'builtin' && builtinType" class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-lucide-lock" class="size-4.5 shrink-0 text-muted" />
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
@@ -192,7 +186,7 @@ function goHome() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SchemaTypeNotFound v-else-if="notFound" />
|
||||
<SchemaTypeNotFound v-else-if="view === 'notFound'" />
|
||||
</template>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -0,0 +1,583 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import router from '@/router'
|
||||
import { TOPBAR_ACTION_ID } from '@/lib/uiSlots'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import {
|
||||
button,
|
||||
calls,
|
||||
deletes,
|
||||
entryPage,
|
||||
failWith,
|
||||
failWithBody,
|
||||
mountAt,
|
||||
networkFailure,
|
||||
pending,
|
||||
puts,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './schemaTestKit'
|
||||
import {
|
||||
byLabel,
|
||||
chip,
|
||||
click,
|
||||
dialog,
|
||||
el,
|
||||
main,
|
||||
nameInput,
|
||||
press,
|
||||
setMax,
|
||||
typeInto,
|
||||
} from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = () => undefined
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await unmountAll()
|
||||
})
|
||||
|
||||
const MORE = 'More actions'
|
||||
const DELETE = 'Delete content type'
|
||||
const CONFIRM_TEXT =
|
||||
"Recipes has no entries. Its fields and its API routes will be deleted. This can't be undone."
|
||||
const FAILED_TITLE = "Couldn't delete the content type"
|
||||
const FAILED_TEXT = 'Something went wrong on our side. Nothing was changed. Try again in a moment.'
|
||||
|
||||
const more = () => byLabel(MORE) as HTMLButtonElement | null
|
||||
const menuItem = () =>
|
||||
[...document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')].find(
|
||||
(item) => text(item) === DELETE,
|
||||
)
|
||||
const dialogButton = (label: string) =>
|
||||
[...(dialog()?.querySelectorAll('button') ?? [])].find((candidate) =>
|
||||
text(candidate).startsWith(label),
|
||||
) as HTMLButtonElement | undefined
|
||||
|
||||
async function chooseDelete() {
|
||||
await click(more())
|
||||
await click(menuItem())
|
||||
}
|
||||
|
||||
async function openConfirm(options = {}) {
|
||||
await mountAt('/schema/recipes', options)
|
||||
await chooseDelete()
|
||||
}
|
||||
|
||||
describe('The overflow menu', () => {
|
||||
it('holds one error-coloured Delete content type item after Save on desktop', async () => {
|
||||
await mountAt('/schema/recipes')
|
||||
|
||||
const buttons = [...document.body.querySelectorAll('[data-testid="schema-builder"] button')]
|
||||
expect(buttons.indexOf(more()!)).toBeGreaterThan(buttons.indexOf(button('Save')!))
|
||||
await click(more())
|
||||
|
||||
const items = [...document.body.querySelectorAll('[role="menuitem"]')]
|
||||
expect(items.map(text)).toEqual([DELETE])
|
||||
expect(items[0]!.className).toContain('text-error')
|
||||
expect(items[0]!.querySelector('svg.iconify--lucide')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('is the topbar action on mobile', async () => {
|
||||
await mountAt('/schema/recipes', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(more()).not.toBeNull()
|
||||
expect(more()!.closest('[data-testid="schema-builder"]')).toBeNull()
|
||||
expect(document.getElementById(TOPBAR_ACTION_ID)!.contains(more())).toBe(true)
|
||||
expect(document.body.querySelectorAll(`[aria-label="${MORE}"]`)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/schema/new', 'no overflow menu on a new type'],
|
||||
['/schema/articles', 'no overflow menu on a built-in type'],
|
||||
])('has no menu on %s', async (path) => {
|
||||
await mountAt(path)
|
||||
|
||||
expect(more()).toBeNull()
|
||||
})
|
||||
|
||||
it('has no menu for a user who is neither owner nor admin', async () => {
|
||||
await mountAt('/schema/recipes', { role: 'user' })
|
||||
|
||||
expect(more()).toBeNull()
|
||||
expect(text(main())).toContain("You don't have access to Schema")
|
||||
})
|
||||
|
||||
it('is enabled on a saved type and disabled while the PUT is in flight', async () => {
|
||||
const gate = pending()
|
||||
await mountAt('/schema/events', { update: () => gate.promise })
|
||||
expect(more()!.disabled).toBe(false)
|
||||
await setMax('2000')
|
||||
await click(button('Save'))
|
||||
await click(button('Apply changes'))
|
||||
|
||||
expect(more()!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('is disabled while a delete is in flight', async () => {
|
||||
const gate = pending()
|
||||
await openConfirm({ remove: () => gate.promise })
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
expect(more()!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('closes with Esc and returns the focus to the button', async () => {
|
||||
await mountAt('/schema/recipes')
|
||||
await click(more())
|
||||
expect(menuItem()).toBeDefined()
|
||||
|
||||
await press(document.activeElement ?? document.body, 'Escape')
|
||||
await vi.waitFor(() => expect(menuItem()).toBeUndefined())
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(more()))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete blocked, the type has entries (state 5)', () => {
|
||||
it('opens the blocked dialog with no request and no delete button', async () => {
|
||||
await mountAt('/schema/events')
|
||||
calls.length = 0
|
||||
await chooseDelete()
|
||||
|
||||
expect(deletes).toHaveLength(0)
|
||||
expect(calls).toEqual([])
|
||||
const content = text(dialog())
|
||||
expect(content).toContain("You can't delete Events yet")
|
||||
expect(content).toContain('A content type can only be deleted when it has no entries.')
|
||||
expect(content).toContain('Events has 37 entries')
|
||||
expect(content).toContain('Delete all of its entries first, then delete the content type.')
|
||||
expect(dialogButton(DELETE)).toBeUndefined()
|
||||
expect(dialogButton('Close')).toBeDefined()
|
||||
expect(dialogButton('View entries')).toBeDefined()
|
||||
expect(dialog()!.querySelector('svg.iconify--lucide')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('groups the count like the info bar', async () => {
|
||||
await mountAt('/schema/events', { entries: () => entryPage(1200) })
|
||||
await chooseDelete()
|
||||
|
||||
expect(text(dialog())).toContain('Events has 1,200 entries')
|
||||
})
|
||||
|
||||
it('says "1 entry" for a single entry', async () => {
|
||||
await mountAt('/schema/recipes', { entries: () => entryPage(1) })
|
||||
await chooseDelete()
|
||||
|
||||
expect(text(dialog())).toContain('Recipes has 1 entry')
|
||||
})
|
||||
|
||||
it('opens with the focus on the dialog and not on View entries', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await chooseDelete()
|
||||
|
||||
expect(dialog()!.contains(document.activeElement)).toBe(true)
|
||||
expect(document.activeElement).not.toBe(dialogButton('View entries'))
|
||||
})
|
||||
|
||||
it.each(['Close', 'icon', 'Escape'])(
|
||||
'closes with %s, keeps the edits and returns the focus to the menu button',
|
||||
async (way) => {
|
||||
await mountAt('/schema/events')
|
||||
await typeInto(nameInput(), 'Events and talks')
|
||||
await chooseDelete()
|
||||
|
||||
if (way === 'Close') {
|
||||
await click(dialogButton('Close'))
|
||||
} else if (way === 'icon') {
|
||||
await click(dialog()!.querySelector('button[aria-label="Close"]'))
|
||||
} else {
|
||||
await press(dialog(), 'Escape')
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(nameInput()!.value).toBe('Events and talks')
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(document.activeElement).toBe(more())
|
||||
expect(deletes).toHaveLength(0)
|
||||
},
|
||||
)
|
||||
|
||||
it('goes to the entries of the type with View entries', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await chooseDelete()
|
||||
|
||||
await click(dialogButton('View entries'))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/content/events'))
|
||||
expect(dialog()).toBeNull()
|
||||
expect(deletes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows the saved count and never mentions the edits', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await setMax('2000')
|
||||
await chooseDelete()
|
||||
|
||||
expect(text(dialog())).toContain('Events has 37 entries')
|
||||
expect(text(dialog())).not.toMatch(/unsaved|changes/i)
|
||||
expect(puts).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete confirm, the type has no entries (state 6)', () => {
|
||||
it('opens with the copy, the key in mono, Cancel and the error action', async () => {
|
||||
await openConfirm()
|
||||
|
||||
expect(deletes).toHaveLength(0)
|
||||
const content = text(dialog())
|
||||
expect(content).toContain('Delete Recipes?')
|
||||
expect(content).toContain(CONFIRM_TEXT)
|
||||
expect(content).toContain('Key: recipes')
|
||||
expect(dialog()!.querySelector('.font-mono')?.textContent).toBe('recipes')
|
||||
expect(dialogButton('Cancel')).toBeDefined()
|
||||
expect(dialogButton(DELETE)).toBeDefined()
|
||||
})
|
||||
|
||||
it('never gives the destructive button the first focus', async () => {
|
||||
await openConfirm()
|
||||
|
||||
expect(dialog()!.contains(document.activeElement)).toBe(true)
|
||||
expect(document.activeElement).not.toBe(dialogButton(DELETE))
|
||||
})
|
||||
|
||||
it.each(['Cancel', 'icon', 'Escape'])(
|
||||
'%s closes it with nothing sent and the focus back on the menu button',
|
||||
async (way) => {
|
||||
await openConfirm()
|
||||
|
||||
if (way === 'Cancel') {
|
||||
await click(dialogButton('Cancel'))
|
||||
} else if (way === 'icon') {
|
||||
await click(dialog()!.querySelector('button[aria-label="Close"]'))
|
||||
} else {
|
||||
await press(dialog(), 'Escape')
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(deletes).toHaveLength(0)
|
||||
expect(document.activeElement).toBe(more())
|
||||
},
|
||||
)
|
||||
|
||||
it('shows the saved count even with unsaved edits and leaves them alone on Cancel', async () => {
|
||||
await mountAt('/schema/recipes')
|
||||
await typeInto(nameInput(), 'Recipes!')
|
||||
await chooseDelete()
|
||||
|
||||
expect(text(dialog())).toContain('Delete Recipes?')
|
||||
expect(text(dialog())).not.toContain('Recipes!')
|
||||
await click(dialogButton('Cancel'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
|
||||
expect(nameInput()!.value).toBe('Recipes!')
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(puts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is a bottom sheet on mobile with 44 px controls', async () => {
|
||||
await mountAt('/schema/recipes', { width: MOBILE_WIDTH })
|
||||
await chooseDelete()
|
||||
|
||||
expect(dialog()!.className).toContain('bottom-0')
|
||||
expect(dialogButton(DELETE)!.className).toContain('h-11')
|
||||
expect(dialogButton('Cancel')!.className).toContain('h-11')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Deleting (in flight)', () => {
|
||||
it('sends the DELETE once, shows the loader, disables Cancel and cannot be dismissed', async () => {
|
||||
const gate = pending()
|
||||
await openConfirm({ remove: () => gate.promise })
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
await click(dialogButton('Deleting'))
|
||||
|
||||
expect(deletes).toEqual(['/v1/content-type/recipes'])
|
||||
expect(dialogButton('Deleting…')!.disabled).toBe(true)
|
||||
expect(dialogButton('Cancel')!.disabled).toBe(true)
|
||||
expect(dialog()!.querySelector('button[aria-label="Close"]')).toBeNull()
|
||||
await press(dialog(), 'Escape')
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(dialog()!.contains(document.activeElement)).toBe(true)
|
||||
const focused = document.activeElement as HTMLElement
|
||||
expect(focused.tagName).toBe('SPAN')
|
||||
expect(focused.getAttribute('tabindex')).toBe('-1')
|
||||
expect(text(focused)).toBe('Delete Recipes?')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Deleted (204)', () => {
|
||||
it('closes, leaves for /schema with no prompt, drops the type and toasts', async () => {
|
||||
const actions: string[] = []
|
||||
await mountAt('/schema/recipes', { actions })
|
||||
await typeInto(nameInput(), 'Recipes!')
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
await chooseDelete()
|
||||
calls.length = 0
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
await flushPromises()
|
||||
expect(text(document.body)).not.toContain('Leave without saving?')
|
||||
expect(dialog()).toBeNull()
|
||||
expect(useContentTypesStore().items.map((item) => item.key)).toEqual([
|
||||
'events',
|
||||
'press-mentions',
|
||||
])
|
||||
expect(calls).not.toContain('/v1/content-type')
|
||||
expect(calls.filter((url) => url.startsWith('/v1/content-type/recipes'))).toEqual([])
|
||||
expect(text(document.body)).toContain('Content type deleted')
|
||||
expect(text(document.body)).toContain('Recipes was deleted.')
|
||||
expect(actions).toContain('drop')
|
||||
})
|
||||
|
||||
it('announces the renamed type after a save', async () => {
|
||||
await mountAt('/schema/recipes')
|
||||
await typeInto(nameInput(), 'Recipes box')
|
||||
await click(button('Save'))
|
||||
await vi.waitFor(() => expect(chip()).not.toBe('Unsaved changes'))
|
||||
|
||||
await chooseDelete()
|
||||
expect(text(dialog())).toContain('Delete Recipes box?')
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
expect(text(document.body)).toContain('Recipes box was deleted.')
|
||||
})
|
||||
|
||||
it('shows the not found page when the address is opened again', async () => {
|
||||
await openConfirm()
|
||||
await click(dialogButton(DELETE))
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
|
||||
await router.push('/schema/recipes')
|
||||
await flushPromises()
|
||||
|
||||
expect(text(main())).toContain('Content type not found')
|
||||
expect(text(main())).toContain('It may have been deleted, or the address may be mistyped.')
|
||||
})
|
||||
|
||||
it('deletes after a refused save and the banner goes with the page', async () => {
|
||||
await mountAt('/schema/recipes', {
|
||||
update: failWithBody(409, 'schema_incompatible'),
|
||||
entries: () => entryPage(0),
|
||||
})
|
||||
await typeInto(nameInput(), 'Recipes!')
|
||||
await click(button('Save'))
|
||||
await click(button('Back to editing'))
|
||||
expect(text(document.body)).toContain('Nothing was changed')
|
||||
|
||||
await chooseDelete()
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
expect(text(document.body)).not.toContain('Nothing was changed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Other answers of DELETE', () => {
|
||||
it('keeps the dialog on a 409 with the fallback copy and the store untouched', async () => {
|
||||
await openConfirm({ remove: failWithBody(409, 'content_type_has_entries') })
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
const content = text(dialog())
|
||||
expect(content).toContain('Delete Recipes?')
|
||||
expect(content).toContain('Recipes now has entries')
|
||||
expect(content).toContain(
|
||||
'Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again.',
|
||||
)
|
||||
expect(dialogButton(DELETE)).toBeUndefined()
|
||||
expect(dialogButton('Close')).toBeDefined()
|
||||
expect(dialogButton('View entries')).toBeDefined()
|
||||
expect(router.currentRoute.value.path).toBe('/schema/recipes')
|
||||
expect(useContentTypesStore().items.map((item) => item.key)).toContain('recipes')
|
||||
})
|
||||
|
||||
it('goes to the entries from the fallback', async () => {
|
||||
await openConfirm({ remove: failWithBody(409, 'content_type_has_entries') })
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
await click(dialogButton('View entries'))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/content/recipes'))
|
||||
})
|
||||
|
||||
it('reopens the confirm dialog after closing the fallback, the count is not refreshed', async () => {
|
||||
await openConfirm({ remove: failWithBody(409, 'content_type_has_entries') })
|
||||
await click(dialogButton(DELETE))
|
||||
await click(dialogButton('Close'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
calls.length = 0
|
||||
|
||||
await chooseDelete()
|
||||
|
||||
expect(text(dialog())).toContain(CONFIRM_TEXT)
|
||||
expect(text(dialog())).not.toContain('now has entries')
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('drops the type and shows the not found page on a 404', async () => {
|
||||
await openConfirm({ remove: failWithBody(404, 'content_type_not_found') })
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(useContentTypesStore().items.map((item) => item.key)).not.toContain('recipes')
|
||||
expect(text(main())).toContain('Content type not found')
|
||||
expect(router.currentRoute.value.path).toBe('/schema/recipes')
|
||||
})
|
||||
|
||||
it('shows the access page on a 403', async () => {
|
||||
await openConfirm({ remove: failWithBody(403, 'Only owners and admins can do this') })
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(text(main())).toContain("You don't have access to Schema")
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', failWith(500)],
|
||||
['a network failure', networkFailure],
|
||||
])('keeps the dialog with an alert on %s and enables both buttons', async (_, remove) => {
|
||||
await openConfirm({ remove })
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
const content = text(dialog())
|
||||
expect(content).toContain(FAILED_TITLE)
|
||||
expect(content).toContain(FAILED_TEXT)
|
||||
expect(dialogButton('Cancel')!.disabled).toBe(false)
|
||||
expect(dialogButton(DELETE)!.disabled).toBe(false)
|
||||
expect(useContentTypesStore().items.map((item) => item.key)).toContain('recipes')
|
||||
expect(router.currentRoute.value.path).toBe('/schema/recipes')
|
||||
})
|
||||
|
||||
it('retries the same DELETE and clears the alert while it is in flight', async () => {
|
||||
let attempt = 0
|
||||
const gate = pending()
|
||||
await openConfirm({
|
||||
remove: () => {
|
||||
attempt++
|
||||
if (attempt === 1) {
|
||||
throw new Error('boom')
|
||||
}
|
||||
return gate.promise
|
||||
},
|
||||
})
|
||||
await click(dialogButton(DELETE))
|
||||
expect(text(dialog())).toContain(FAILED_TITLE)
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
expect(deletes).toEqual(['/v1/content-type/recipes', '/v1/content-type/recipes'])
|
||||
expect(text(dialog())).not.toContain(FAILED_TITLE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Offline', () => {
|
||||
function setOnline(value: boolean) {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(value)
|
||||
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
|
||||
}
|
||||
|
||||
it('disables the destructive action offline and sends nothing on reconnect', async () => {
|
||||
await openConfirm()
|
||||
|
||||
setOnline(false)
|
||||
await flushPromises()
|
||||
expect(dialogButton(DELETE)!.disabled).toBe(true)
|
||||
|
||||
setOnline(true)
|
||||
await flushPromises()
|
||||
expect(dialogButton(DELETE)!.disabled).toBe(false)
|
||||
expect(deletes).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Offline per stage', () => {
|
||||
function setOnline(value: boolean) {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(value)
|
||||
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
|
||||
}
|
||||
|
||||
it('keeps View entries enabled offline in the blocked stage', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await chooseDelete()
|
||||
|
||||
setOnline(false)
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogButton('View entries')!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps View entries enabled offline in the conflict stage', async () => {
|
||||
await openConfirm({ remove: failWithBody(409, 'content_type_has_entries') })
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
setOnline(false)
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogButton('View entries')!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('colours the destructive action as an error and the informational one as primary', async () => {
|
||||
await openConfirm()
|
||||
expect(dialogButton(DELETE)!.className).toContain('bg-error')
|
||||
await click(dialogButton('Cancel'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
})
|
||||
|
||||
it('colours View entries as primary', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await chooseDelete()
|
||||
|
||||
expect(dialogButton('View entries')!.className).toContain('bg-primary')
|
||||
expect(dialogButton('View entries')!.className).not.toContain('bg-error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unauthenticated answers', () => {
|
||||
it('stays silent on a 401 and leaves the confirm dialog as it was', async () => {
|
||||
await openConfirm({ remove: failWith(401) })
|
||||
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
const content = text(dialog())
|
||||
expect(content).toContain('Delete Recipes?')
|
||||
expect(content).not.toContain(FAILED_TITLE)
|
||||
expect(useContentTypesStore().items.map((item) => item.key)).toContain('recipes')
|
||||
expect(dialogButton(DELETE)!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('gives the menu button an accessible name and a 44 px mobile size', async () => {
|
||||
await mountAt('/schema/recipes', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(more()!.getAttribute('aria-label')).toBe(MORE)
|
||||
expect(more()!.className).toContain('size-11')
|
||||
})
|
||||
|
||||
it('has no in-flight focus on the menu button', async () => {
|
||||
const gate = pending()
|
||||
await openConfirm({ remove: () => gate.promise })
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
expect(el('[role="alertdialog"]')!.getAttribute('aria-busy')).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -131,6 +131,7 @@ export interface SchemaMount {
|
||||
create?: (body: PostedRequest['body']) => unknown
|
||||
update?: (body: PostedRequest['body'], key: string) => unknown
|
||||
entries?: (key: string) => unknown
|
||||
remove?: (key: string) => unknown
|
||||
width?: number
|
||||
content?: Responder
|
||||
builtin?: Responder
|
||||
@@ -142,6 +143,7 @@ let wrapper: VueWrapper | undefined
|
||||
export const calls: string[] = []
|
||||
export const posts: PostedRequest[] = []
|
||||
export const puts: PostedRequest[] = []
|
||||
export const deletes: string[] = []
|
||||
|
||||
export function updatedType(key: string, body: Record<string, unknown>) {
|
||||
const base = customTypes.find((type) => type.key === key)!
|
||||
@@ -152,6 +154,7 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
calls.length = 0
|
||||
posts.length = 0
|
||||
puts.length = 0
|
||||
deletes.length = 0
|
||||
localStorage.clear()
|
||||
setViewportWidth(options.width ?? DESKTOP_WIDTH)
|
||||
const pinia = createPinia()
|
||||
@@ -173,6 +176,10 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
}
|
||||
session.status = 'ready'
|
||||
const request = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'DELETE') {
|
||||
deletes.push(url)
|
||||
return (options.remove ?? (() => undefined))(url.split('/').pop()!)
|
||||
}
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body)) as PostedRequest['body']
|
||||
const key = url.split('/').pop()!
|
||||
|
||||
+8
-1
@@ -74,7 +74,14 @@ const extraSources: Record<string, Source[]> = {
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '3 Review and delete',
|
||||
states: ['Review before saving', 'Applying', 'Refused (schema_incompatible)'],
|
||||
states: [
|
||||
'Review before saving',
|
||||
'Applying',
|
||||
'Refused (schema_incompatible)',
|
||||
'Delete type: blocked',
|
||||
'Delete type: confirm',
|
||||
'Delete type: server fallback (409)',
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
],
|
||||
|
||||
+68
-1
@@ -6651,6 +6651,12 @@ async function schemaFieldErrors(page: Page) {
|
||||
await schemaSettle(page)
|
||||
}
|
||||
|
||||
async function schemaChooseDelete(page: Page) {
|
||||
await page.getByRole('button', { name: 'More actions' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Delete content type' }).click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
}
|
||||
|
||||
async function schemaCategoryChoices(page: Page) {
|
||||
await schemaOpenRow(page, 'Category')
|
||||
await page.locator('[data-testid="choice-row"] input').nth(2).fill('')
|
||||
@@ -6739,7 +6745,22 @@ const schemaStates: Record<string, VisualState> = {
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Field key error': { path: '/schema/new', skip: 'pending' },
|
||||
'Field key error': {
|
||||
path: '/schema/events',
|
||||
api: schemaApi(),
|
||||
steps: async (page) => {
|
||||
await schemaAddField(page, 'Second date')
|
||||
await page.locator('label:has([role="radio"][value="date"])').click()
|
||||
await page
|
||||
.locator('[data-testid="field-inspector"]')
|
||||
.getByRole('button', { name: 'Edit key' })
|
||||
.click()
|
||||
await page.locator('input[name="field-key"]').fill('2nd-date')
|
||||
await page.getByText('1 problem to fix').first().waitFor()
|
||||
await schemaCloseSheet(page)
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
'Choice list editor': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 25 },
|
||||
@@ -6872,6 +6893,52 @@ const schemaStates: Record<string, VisualState> = {
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
'Delete type: blocked': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 15, mobile: 10 },
|
||||
reason: 'the design board draws a miniature of the board and a mobile-sized dialog overlay inside the desktop frame',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
path: '/schema/events',
|
||||
api: schemaApi(),
|
||||
steps: async (page) => {
|
||||
await schemaChooseDelete(page)
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
'Delete type: confirm': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 48, mobile: 10 },
|
||||
reason: 'the design board draws a miniature of the board and a mobile-sized dialog overlay inside the desktop frame',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
path: '/schema/recipes',
|
||||
api: schemaApi(),
|
||||
steps: async (page) => {
|
||||
await schemaChooseDelete(page)
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
'Delete type: server fallback (409)': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 35, mobile: 10 },
|
||||
reason: 'the design board draws a miniature of the board and a mobile-sized dialog overlay inside the desktop frame',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
path: '/schema/recipes',
|
||||
api: schemaApi({
|
||||
'DELETE /v1/content-type/recipes': { status: 409, body: 'content_type_has_entries' },
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await schemaChooseDelete(page)
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: 'Delete content type' })
|
||||
.click()
|
||||
await page.getByText('Recipes now has entries').waitFor()
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
Offline: {
|
||||
path: '/schema/events',
|
||||
api: schemaApi(),
|
||||
|
||||
Reference in new issue
Block a user