mirror of
https://github.com/cypress-io/cypress.git
synced 2026-05-05 06:20:44 -05:00
feat: adding alert component with markdown (#19152)
Co-authored-by: ElevateBart <ledouxb@gmail.com> Co-authored-by: Barthélémy Ledoux <bart@cypress.io>
This commit is contained in:
Vendored
+1
@@ -4,6 +4,7 @@
|
||||
"dictionaryDefinitions": [],
|
||||
"dictionaries": [],
|
||||
"words": [
|
||||
"composables",
|
||||
"Iconify",
|
||||
"Lachlan",
|
||||
"msapplication",
|
||||
|
||||
+1
-1
@@ -9,6 +9,6 @@ module.exports = {
|
||||
localSchemaFile: path.join(__dirname, 'packages/graphql/schemas/schema.graphql'),
|
||||
},
|
||||
tagName: 'gql',
|
||||
includes: [path.join(__dirname, 'packages/{launchpad,app,frontend-shared}/src/**/*.vue')],
|
||||
includes: [path.join(__dirname, 'packages/{launchpad,app,frontend-shared}/src/**/*.{vue,ts,js,tsx,jsx}')],
|
||||
},
|
||||
}
|
||||
|
||||
+14
-14
@@ -50,9 +50,9 @@ generates:
|
||||
flattenGeneratedTypes: true
|
||||
schema: 'packages/graphql/schemas/schema.graphql'
|
||||
documents:
|
||||
- './packages/frontend-shared/src/gql-components/**/*.vue'
|
||||
- './packages/app/src/**/*.vue'
|
||||
- './packages/launchpad/src/**/*.vue'
|
||||
- './packages/frontend-shared/src/gql-components/**/*.{vue,ts,tsx,js,jsx}'
|
||||
- './packages/app/src/**/*.{vue,ts,tsx,js,jsx}'
|
||||
- './packages/launchpad/src/**/*.{vue,ts,tsx,js,jsx}'
|
||||
plugins:
|
||||
- add:
|
||||
content: '/* eslint-disable */'
|
||||
@@ -92,23 +92,23 @@ generates:
|
||||
- 'typescript'
|
||||
|
||||
###
|
||||
# All of the GraphQL Query/Mutation documents we import for use in the .vue
|
||||
# All of the GraphQL Query/Mutation documents we import for use in the .{vue,ts,tsx,js,jsx}
|
||||
# files for useQuery / useMutation, as well as types associated with the fragments
|
||||
###
|
||||
'./packages/launchpad/src/generated/graphql.ts':
|
||||
documents:
|
||||
- './packages/launchpad/src/**/*.vue'
|
||||
- './packages/frontend-shared/src/**/*.vue'
|
||||
- './packages/launchpad/src/**/*.{vue,ts,tsx,js,jsx}'
|
||||
- './packages/frontend-shared/src/**/*.{vue,ts,tsx,js,jsx}'
|
||||
<<: *vueOperations
|
||||
|
||||
'./packages/app/src/generated/graphql.ts':
|
||||
documents:
|
||||
- './packages/app/src/**/*.vue'
|
||||
- './packages/frontend-shared/src/**/*.vue'
|
||||
- './packages/app/src/**/*.{vue,ts,tsx,js,jsx}'
|
||||
- './packages/frontend-shared/src/**/*.{vue,ts,tsx,js,jsx}'
|
||||
<<: *vueOperations
|
||||
|
||||
'./packages/frontend-shared/src/generated/graphql.ts':
|
||||
documents: './packages/frontend-shared/src/gql-components/**/*.vue'
|
||||
documents: './packages/frontend-shared/src/gql-components/**/*.{vue,ts,tsx,js,jsx}'
|
||||
<<: *vueOperations
|
||||
###
|
||||
# All GraphQL documents imported into the .spec.tsx files for component testing.
|
||||
@@ -117,16 +117,16 @@ generates:
|
||||
###
|
||||
'./packages/launchpad/src/generated/graphql-test.ts':
|
||||
documents:
|
||||
- './packages/launchpad/src/**/*.vue'
|
||||
- './packages/frontend-shared/src/**/*.vue'
|
||||
- './packages/launchpad/src/**/*.{vue,ts,tsx,js,jsx}'
|
||||
- './packages/frontend-shared/src/**/*.{vue,ts,tsx,js,jsx}'
|
||||
<<: *vueTesting
|
||||
|
||||
'./packages/app/src/generated/graphql-test.ts':
|
||||
documents:
|
||||
- './packages/app/src/**/*.vue'
|
||||
- './packages/frontend-shared/src/**/*.vue'
|
||||
- './packages/app/src/**/*.{vue,ts,tsx,js,jsx}'
|
||||
- './packages/frontend-shared/src/**/*.{vue,ts,tsx,js,jsx}'
|
||||
<<: *vueTesting
|
||||
|
||||
'./packages/frontend-shared/src/generated/graphql-test.ts':
|
||||
documents: './packages/frontend-shared/src/gql-components/**/*.vue'
|
||||
documents: './packages/frontend-shared/src/gql-components/**/*.{vue,ts,tsx,js,jsx}'
|
||||
<<: *vueTesting
|
||||
|
||||
@@ -166,6 +166,10 @@ export const cyColors = {
|
||||
...customColors.red,
|
||||
DEFAULT: customColors.red[500],
|
||||
},
|
||||
info: {
|
||||
...customColors.indigo,
|
||||
DEFAULT: customColors.indigo[500],
|
||||
},
|
||||
warning: {
|
||||
...customColors.orange,
|
||||
DEFAULT: customColors.orange[500],
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"generate-shiki-theme": "node ./script/generate-shiki-theme.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@toycode/markdown-it-class": "1.2.4",
|
||||
"markdown-it": "12.2.0",
|
||||
"shiki": "^0.9.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 13L13 3M3 3L13 13"
|
||||
class="icon-dark" stroke="#1B1E2E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 236 B |
@@ -0,0 +1,255 @@
|
||||
import CoffeeIcon from '~icons/mdi/coffee'
|
||||
import LoadingIcon from '~icons/mdi/loading'
|
||||
import faker from 'faker'
|
||||
import Alert from './Alert.vue'
|
||||
import { defaultMessages } from '../locales/i18n'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const messages = defaultMessages.components.alert
|
||||
|
||||
const alertBodySelector = '[data-testid=alert-body]'
|
||||
const alertHeaderSelector = '[data-testid=alert-header]'
|
||||
|
||||
const suffixIconSelector = '[data-testid=alert-suffix-icon]'
|
||||
const prefixIconSelector = '[data-testid=alert-prefix-icon]'
|
||||
|
||||
// This divider should eventually be tested inside of a visual regression test.
|
||||
const dividerLineSelector = '[data-testid=alert-body-divider]'
|
||||
const dismissSelector = `[aria-label=${messages.dismissAriaLabel}]`
|
||||
|
||||
const alertTitle = faker.hacker.phrase()
|
||||
const alertBodyContent = faker.lorem.sentences(2)
|
||||
|
||||
const makeDismissibleProps = () => {
|
||||
const modelValue = ref(true)
|
||||
const methods = {
|
||||
'onUpdate:modelValue': (newValue) => {
|
||||
modelValue.value = newValue
|
||||
},
|
||||
}
|
||||
|
||||
return { modelValue, methods }
|
||||
}
|
||||
|
||||
const prefixIcon = () => <CoffeeIcon data-testid="coffee-icon"/>
|
||||
const suffixIcon = () => <LoadingIcon data-testid="loading-icon" class="animate-spin"/>
|
||||
|
||||
describe('<Alert />', () => {
|
||||
describe('classes', () => {
|
||||
it('can change the text and background color for the alert', () => {
|
||||
cy.mount(() => <Alert headerClass="underline text-pink-500" alertClass="bg-pink-100" icon={suffixIcon}/>)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prefix', () => {
|
||||
it('renders the icon prop as a prefix', () => {
|
||||
cy.mount(() => <Alert status="info" icon={CoffeeIcon} />)
|
||||
.get(prefixIconSelector).should('be.visible')
|
||||
})
|
||||
|
||||
it('renders the prefixIcon slot', () => {
|
||||
cy.mount(() => <Alert v-slots={{ prefixIcon }} />)
|
||||
.get('[data-testid=coffee-icon]').should('be.visible')
|
||||
})
|
||||
|
||||
it('renders the prefixIcon slot even when an icon is passed in', () => {
|
||||
cy.mount(() => (<Alert
|
||||
v-slots={{ prefixIcon }}
|
||||
icon={() => <LoadingIcon data-testid="loading-icon" />}
|
||||
/>))
|
||||
.get('[data-testid=coffee-icon]').should('be.visible')
|
||||
.get('[data-testid=loading-icon]').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('suffix', () => {
|
||||
it('renders the suffixIcon slot', () => {
|
||||
cy.mount(() => <Alert title="Alert" v-slots={{ suffixIcon }} />)
|
||||
.get('[data-testid=loading-icon]').should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
describe('static', () => {
|
||||
it('renders any body content and is open by default', () => {
|
||||
cy.mount(() => (
|
||||
<div class="space-y-2 text-center p-4">
|
||||
<Alert title="Alert">
|
||||
<p data-testid="body-content">{ faker.lorem.paragraphs(5) }</p>
|
||||
</Alert>
|
||||
</div>
|
||||
))
|
||||
|
||||
cy.get('[data-testid=body-content]').should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dismissible', () => {
|
||||
it('renders any body content and is open by default', () => {
|
||||
const { modelValue, methods } = makeDismissibleProps()
|
||||
|
||||
cy.mount(() => (
|
||||
<div class="space-y-2 text-center p-4">
|
||||
<Alert title="Alert" dismissible modelValue={modelValue.value} {...methods}>
|
||||
<p data-testid="body-content">{ faker.lorem.paragraphs(5) }</p>
|
||||
</Alert>
|
||||
</div>
|
||||
))
|
||||
|
||||
cy.get('[data-testid=body-content]').should('be.visible')
|
||||
})
|
||||
|
||||
it('cannot be collapsed', () => {
|
||||
const { modelValue, methods } = makeDismissibleProps()
|
||||
|
||||
cy.mount(() => (<Alert title="Alert" dismissible modelValue={modelValue.value} {...methods}>
|
||||
<p data-testid="body-content">{ faker.lorem.paragraphs(5) }</p>
|
||||
</Alert>))
|
||||
.get(alertBodySelector).should('be.visible')
|
||||
.get(alertHeaderSelector).click()
|
||||
.get(alertBodySelector).should('be.visible')
|
||||
})
|
||||
|
||||
it('has a "dismiss" suffixIcon by default', () => {
|
||||
const { modelValue, methods } = makeDismissibleProps()
|
||||
|
||||
cy.mount(() => (
|
||||
<div class="space-y-2 text-center p-4">
|
||||
<Alert title="Alert" status="info" dismissible modelValue={modelValue.value} {...methods}/>
|
||||
</div>
|
||||
))
|
||||
|
||||
cy.get(suffixIconSelector)
|
||||
.should('be.visible')
|
||||
.and('have.attr', 'aria-label', messages.dismissAriaLabel)
|
||||
})
|
||||
|
||||
it('can be dismissed', () => {
|
||||
const { modelValue, methods } = makeDismissibleProps()
|
||||
|
||||
cy.mount(() => (<div class="space-y-2 text-center p-4">
|
||||
<Alert title="Alert" dismissible modelValue={modelValue.value} {...methods}/>
|
||||
</div>))
|
||||
|
||||
cy.get(suffixIconSelector).focus().click()
|
||||
.get(alertHeaderSelector).should('not.exist')
|
||||
})
|
||||
|
||||
it('accepts a custom dismiss icon, via slot', () => {
|
||||
const { modelValue, methods } = makeDismissibleProps()
|
||||
|
||||
cy.mount(() => <Alert title="Alert" dismissible v-slots={{ suffixIcon }} modelValue={modelValue.value} {...methods}/>)
|
||||
})
|
||||
|
||||
it('can create a dismiss button via the suffixIcons slot props', () => {
|
||||
const { modelValue, methods } = makeDismissibleProps()
|
||||
const slots = {
|
||||
suffixIcon ({ onClick, ariaLabel }) {
|
||||
return <CoffeeIcon onClick={onClick} aria-label={ariaLabel}/>
|
||||
},
|
||||
}
|
||||
|
||||
cy.mount(() => <Alert title="Alert" dismissible v-slots={slots} modelValue={modelValue.value} {...methods} />)
|
||||
cy.get(dismissSelector).click()
|
||||
cy.get(alertHeaderSelector).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with body content', () => {
|
||||
it('shows the body content initially', () => {
|
||||
const types = [{ 'dismissible': true }, { 'static': true }] as const
|
||||
|
||||
const alerts = types.map((type) => {
|
||||
const { modelValue, methods } = makeDismissibleProps()
|
||||
|
||||
return (<div>
|
||||
<h2 class="capitalize">{Object.keys(type)[0]}</h2>
|
||||
<Alert title={alertTitle} modelValue={modelValue.value} {...type} {...methods}>
|
||||
<p>{ faker.lorem.paragraphs(2) }</p>
|
||||
</Alert>
|
||||
</div>)
|
||||
})
|
||||
|
||||
cy.mount(() => <div class="space-y-2 p-4">{ alerts }</div>)
|
||||
cy.get(alertBodySelector).each((el) => cy.wrap(el).should('be.visible').get(dividerLineSelector).should('be.visible'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('without body content', () => {
|
||||
it('can be dismissed', () => {
|
||||
const { modelValue, methods } = makeDismissibleProps()
|
||||
|
||||
cy.mount(<Alert collapsible title={alertTitle} modelValue={modelValue.value} {...methods}/>)
|
||||
cy.get(alertBodySelector).should('not.exist')
|
||||
cy.get(alertHeaderSelector).click().get(alertBodySelector).should('not.exist')
|
||||
})
|
||||
|
||||
it('renders each alert type without the divider line', () => {
|
||||
const types = [{ 'dismissible': true }, { 'collapsible': true }, { 'static': true }]
|
||||
|
||||
const alerts = types.map((type) => (<div>
|
||||
<h2 class="capitalize">{Object.keys(type)[0]}</h2>
|
||||
<Alert {...type} title={alertTitle} />
|
||||
</div>
|
||||
))
|
||||
|
||||
cy.mount(() => <div class="space-y-2 p-4">{ alerts }</div>)
|
||||
cy.get(alertHeaderSelector).each((el) => cy.wrap(el).click()).get(dividerLineSelector).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('collapsible', () => {
|
||||
beforeEach(() => {
|
||||
cy.mount(() => (
|
||||
<div class="space-y-2 text-center p-4">
|
||||
<Alert status="success" collapsible icon={CoffeeIcon} title={alertTitle}>{alertBodyContent}</Alert>
|
||||
</div>
|
||||
))
|
||||
})
|
||||
|
||||
it('should not have a suffix icon', () => {
|
||||
cy.get(suffixIconSelector).should('not.exist')
|
||||
cy.get(dismissSelector).should('not.exist')
|
||||
})
|
||||
|
||||
it('renders the alert title', () => {
|
||||
cy.get(alertHeaderSelector).should('be.visible').and('have.text', alertTitle)
|
||||
})
|
||||
|
||||
it('the body content is collapsed by default', () => {
|
||||
cy.get(alertBodySelector).should('not.exist')
|
||||
})
|
||||
|
||||
it('can be expanded and collapsed to show the body content', () => {
|
||||
cy.get(alertHeaderSelector).click()
|
||||
cy.get(alertBodySelector).should('be.visible').and('have.text', alertBodyContent)
|
||||
cy.get(alertHeaderSelector).click()
|
||||
cy.get(alertBodySelector).should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('playground', () => {
|
||||
it('renders', () => {
|
||||
const { modelValue, methods } = makeDismissibleProps()
|
||||
|
||||
cy.mount(() => {
|
||||
return (
|
||||
<div class="space-y-2 text-center p-4">
|
||||
<Alert status="success" collapsible icon={CoffeeIcon} title="Coffee, please">
|
||||
Delicious. Yum.
|
||||
<button class="bg-white rounded ml-2 px-2">Focusable</button>
|
||||
</Alert>
|
||||
<Alert status="info" collapsible title="An info alert">Just letting you know what's up.</Alert>
|
||||
<Alert status="warning">Nothing good is happening here!</Alert>
|
||||
<Alert icon={CoffeeIcon}
|
||||
dismissible
|
||||
status="error"
|
||||
modelValue={modelValue.value}
|
||||
{...methods}>Close me, please!</Alert>
|
||||
<Alert v-slots={{ suffixIcon }} collapsible status="default">A notice.</Alert>
|
||||
<Alert>Default alert</Alert>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<Collapsible
|
||||
v-if="modelValue"
|
||||
lazy
|
||||
:initially-open="initiallyOpen"
|
||||
:disable="!canCollapse"
|
||||
class="rounded-t rounded-b outline-none overflow-hidden group"
|
||||
:class="[
|
||||
classes.alertClass,
|
||||
classes.headerClass,
|
||||
{[`hocus-default border-1 border-transparent rounded ${classes.ring}`]: canCollapse}]"
|
||||
height="300"
|
||||
>
|
||||
<template #target="{ open }">
|
||||
<div
|
||||
data-testid="alert-header"
|
||||
class="grid grid-cols-1 group"
|
||||
:class="{
|
||||
'cursor-pointer': canCollapse,
|
||||
}"
|
||||
>
|
||||
<AlertHeader
|
||||
:title="title"
|
||||
v-bind="classes"
|
||||
:header-class="canCollapse ? 'group-hocus:underline' : ''"
|
||||
:prefix-icon="prefix?.icon"
|
||||
:prefix-icon-class="open ? prefix?.classes + ' rotate-180' : prefix?.classes"
|
||||
:suffix-icon-aria-label="props.dismissible ? t('components.alert.dismissAriaLabel') : ''"
|
||||
:suffix-icon="props.dismissible ? DeleteIcon : null"
|
||||
data-testid="alert"
|
||||
class="rounded min-w-200px p-16px"
|
||||
@suffixIconClicked="$emit('update:modelValue', !modelValue)"
|
||||
>
|
||||
<template
|
||||
v-if="$slots.prefixIcon"
|
||||
#prefixIcon="slotProps"
|
||||
>
|
||||
<slot
|
||||
name="prefixIcon"
|
||||
v-bind="slotProps"
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-if="$slots.suffixIcon"
|
||||
#suffixIcon="slotProps"
|
||||
>
|
||||
<slot
|
||||
name="suffixIcon"
|
||||
v-bind="slotProps"
|
||||
/>
|
||||
</template>
|
||||
</AlertHeader>
|
||||
<div
|
||||
v-if="open"
|
||||
data-testid="alert-body-divider"
|
||||
class="mx-auto h-1px transform w-[calc(100%-32px)] translate-y-1px"
|
||||
:class="[classes.dividerClass]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="$slots.default"
|
||||
class="text-left p-16px"
|
||||
data-testid="alert-body"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</Collapsible>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export type AlertStatus = 'error' | 'warning' | 'info' | 'default' | 'success'
|
||||
|
||||
export type AlertClasses = {
|
||||
headerClass: string,
|
||||
suffixIconClass: string
|
||||
suffixButtonClass: string
|
||||
alertClass: string
|
||||
dividerClass: string
|
||||
ring: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import AlertHeader from './AlertHeader.vue'
|
||||
import DeleteIcon from '~icons/cy/delete_x16.svg'
|
||||
import { computed, useSlots, FunctionalComponent, SVGAttributes } from 'vue'
|
||||
import ChevronDown from '~icons/cy/chevron-down-small_x16.svg'
|
||||
import { useI18n } from '@cy/i18n'
|
||||
import Collapsible from './Collapsible.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const slots = useSlots()
|
||||
|
||||
defineEmits<{
|
||||
(eventName: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
status?: AlertStatus
|
||||
icon?: FunctionalComponent<SVGAttributes, {}>,
|
||||
headerClass?: string,
|
||||
alertClass?: string,
|
||||
dismissible?: boolean,
|
||||
collapsible?: boolean,
|
||||
modelValue?: boolean,
|
||||
}>(), {
|
||||
modelValue: true,
|
||||
alertClass: undefined,
|
||||
status: 'info',
|
||||
icon: undefined,
|
||||
headerClass: undefined,
|
||||
})
|
||||
|
||||
const title = computed(() => props.title ?? 'Alert')
|
||||
|
||||
const alertStyles: Record<AlertStatus, AlertClasses> = {
|
||||
default: {
|
||||
headerClass: 'text-gray-800',
|
||||
suffixIconClass: 'icon-dark-gray-600',
|
||||
suffixButtonClass: 'text-gray-600',
|
||||
alertClass: 'bg-gray-100',
|
||||
dividerClass: 'bg-gray-300',
|
||||
ring: 'hocus:(ring-gray-200 border-gray-300)',
|
||||
},
|
||||
info: {
|
||||
headerClass: 'text-info-700',
|
||||
suffixIconClass: 'icon-dark-info-500',
|
||||
suffixButtonClass: 'text-info-500',
|
||||
alertClass: 'bg-info-100',
|
||||
dividerClass: 'bg-info-300',
|
||||
ring: 'hocus:(ring-info-200 border-info-300)',
|
||||
},
|
||||
warning: {
|
||||
headerClass: 'text-warning-500',
|
||||
suffixIconClass: 'icon-dark-warning-500',
|
||||
suffixButtonClass: 'text-warning-500',
|
||||
alertClass: 'bg-warning-100',
|
||||
dividerClass: 'bg-warning-300',
|
||||
ring: 'hocus:(ring-warning-200 border-warning-300)',
|
||||
},
|
||||
error: {
|
||||
headerClass: 'text-error-600',
|
||||
suffixIconClass: 'icon-dark-error-500',
|
||||
suffixButtonClass: 'text-error-500',
|
||||
alertClass: 'bg-error-100',
|
||||
dividerClass: 'bg-error-300',
|
||||
ring: 'hocus:(ring-error-200 border-error-300)',
|
||||
},
|
||||
success: {
|
||||
headerClass: 'text-success-600',
|
||||
suffixIconClass: 'icon-dark-success-500',
|
||||
suffixButtonClass: 'text-success-500',
|
||||
alertClass: 'bg-success-100',
|
||||
dividerClass: 'bg-success-300',
|
||||
ring: 'hocus:(ring-success-200 border-success-300)',
|
||||
},
|
||||
}
|
||||
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
...alertStyles[props.status],
|
||||
headerClass: props.headerClass ?? alertStyles[props.status].headerClass,
|
||||
alertClass: props.alertClass ?? alertStyles[props.status].alertClass,
|
||||
}
|
||||
})
|
||||
const canCollapse = computed(() => slots.default && props.collapsible)
|
||||
const initiallyOpen = computed(() => slots.default && !props.collapsible)
|
||||
|
||||
const prefix = computed(() => {
|
||||
if (props.icon) return { icon: props.icon }
|
||||
|
||||
if (canCollapse.value) {
|
||||
return {
|
||||
icon: ChevronDown,
|
||||
classes: 'transition transform duration-150 w-16px h-16px',
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
import AlertHeader from './AlertHeader.vue'
|
||||
import CoffeeIcon from '~icons/mdi/coffee'
|
||||
|
||||
describe('<AlertHeader />', () => {
|
||||
it('playground', () => {
|
||||
cy.mount(() => (
|
||||
<div class="text-center p-4">
|
||||
<AlertHeader prefixIcon={CoffeeIcon} title="Coffee, please" suffixIcon={null}/>
|
||||
<AlertHeader title="Alert" />
|
||||
<AlertHeader title="Alert" />
|
||||
</div>
|
||||
))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex gap-8px group items-center relative"
|
||||
:class="[alertClass, headerClass]"
|
||||
>
|
||||
<slot name="prefixIcon">
|
||||
<component
|
||||
:is="prefixIcon"
|
||||
v-if="prefixIcon"
|
||||
data-testid="alert-prefix-icon"
|
||||
class="h-16px w-16px icon-dark-current"
|
||||
:class="prefixIconClass"
|
||||
/>
|
||||
</slot>
|
||||
<h3
|
||||
class="flex-grow font-medium text-left leading-normal underline-current"
|
||||
:class="headerClass"
|
||||
>
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</h3>
|
||||
<div class="relative shrink">
|
||||
<slot
|
||||
name="suffixIcon"
|
||||
v-bind="{ ariaLabel: suffixIconAriaLabel, buttonClasses: suffixButtonClass, iconClasses: suffixIconClass, onClick: onSuffixIconClicked }"
|
||||
>
|
||||
<button
|
||||
v-if="suffixIcon"
|
||||
data-testid="alert-suffix-icon"
|
||||
:aria-label="suffixIconAriaLabel"
|
||||
class="rounded-full flex outline-none h-32px -top-16px -right-8px w-32px hocus:ring-current items-center justify-center absolute hocus:ring-1"
|
||||
:class="suffixButtonClass"
|
||||
@click="onSuffixIconClicked"
|
||||
>
|
||||
<component
|
||||
:is="suffixIcon"
|
||||
class="h-16px w-16px"
|
||||
:class="suffixIconClass"
|
||||
/>
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FunctionalComponent, SVGAttributes } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(eventName: 'suffixIconClicked'): void
|
||||
}>()
|
||||
|
||||
/* eslint-disable vue/require-default-prop */
|
||||
defineProps<{
|
||||
title: string
|
||||
prefixIcon?: FunctionalComponent<SVGAttributes, {}> | null
|
||||
suffixIcon?: FunctionalComponent<SVGAttributes, {}> | null
|
||||
suffixIconAriaLabel?: string
|
||||
alertClass?: string
|
||||
prefixIconClass?: string
|
||||
suffixIconClass?: string
|
||||
headerClass?: string
|
||||
suffixButtonClass?: string
|
||||
}>()
|
||||
|
||||
const onSuffixIconClicked = () => {
|
||||
emit('suffixIconClicked')
|
||||
}
|
||||
</script>
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div
|
||||
tabindex="0"
|
||||
:tabindex="disable ? '-1' : '0'"
|
||||
data-cy="collapsible"
|
||||
@keypress.space.enter.self="toggle"
|
||||
@keypress.space.enter.self="!disable && toggle()"
|
||||
>
|
||||
<div
|
||||
data-cy="collapsible-header"
|
||||
:aria-expanded="isOpen"
|
||||
class="rounded-t focus:outline-indigo-500"
|
||||
:class="{'rounded-b': !isOpen}"
|
||||
@click="toggle"
|
||||
@keypress.space.enter.self="toggle"
|
||||
@click="!disable && toggle()"
|
||||
@keypress.space.enter.self="!disable && toggle()"
|
||||
>
|
||||
<slot
|
||||
name="target"
|
||||
@@ -41,11 +41,13 @@ import { useToggle } from '@vueuse/core'
|
||||
const props = withDefaults(defineProps<{
|
||||
maxHeight?: string
|
||||
initiallyOpen?: boolean
|
||||
lazy?: boolean
|
||||
lazy?: boolean,
|
||||
disable?: boolean,
|
||||
}>(), {
|
||||
initiallyOpen: false,
|
||||
maxHeight: '500px',
|
||||
lazy: false,
|
||||
disable: false,
|
||||
})
|
||||
|
||||
const [isOpen, toggle] = useToggle(props.initiallyOpen)
|
||||
|
||||
@@ -191,7 +191,7 @@ $offset: 1.1em;
|
||||
|
||||
// Keep bg-gray-50 synced with the box-shadows.
|
||||
.line::before, .line:first-child::before {
|
||||
@apply bg-gray-50 text-gray-500 min-w-40px inline-block text-right px-8px mr-16px sticky group-hocus:bg-red-500;
|
||||
@apply bg-gray-50 text-gray-500 min-w-40px inline-block text-right px-8px mr-16px sticky;
|
||||
left: 0px !important;
|
||||
content: counter(step);
|
||||
counter-increment: step;
|
||||
|
||||
+5
-6
@@ -1,6 +1,6 @@
|
||||
import Markdown from './Markdown.vue'
|
||||
import UseMarkdownExample from './UseMarkdownExample.vue'
|
||||
|
||||
describe('<Markdown />', () => {
|
||||
describe('useMarkdown', () => {
|
||||
it('renders styled markdown', () => {
|
||||
const text = `
|
||||
# Heading 1
|
||||
@@ -32,13 +32,12 @@ const heres = {
|
||||
|
||||
`
|
||||
|
||||
cy.mount(<Markdown
|
||||
cy.mount(<UseMarkdownExample
|
||||
options={{ classes: { code: ['bg-pink-200 text-pink-600'], pre: ['bg-orange-100', 'text-orange-500'] } }}
|
||||
text={text}
|
||||
codeClass='bg-gray-100 border-gray-300'
|
||||
/>)
|
||||
|
||||
cy.get('code').first().should('have.class', 'bg-gray-100 border-gray-300')
|
||||
cy.get('pre').first().should('have.class', 'bg-gray-100 border-gray-300')
|
||||
cy.get('ul').should('have.class', 'list-disc')
|
||||
cy.get('code').first().should('have.class', 'bg-pink text-pink-600')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div
|
||||
ref="target"
|
||||
v-html="markdown"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useMarkdown, UseMarkdownOptions } from '../useMarkdown'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
text: string,
|
||||
options: UseMarkdownOptions
|
||||
}>()
|
||||
|
||||
const target = ref()
|
||||
|
||||
const { markdown } = useMarkdown(target, props.text, props.options)
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Add styling for markdown components here.
|
||||
* NOTICE: For Syntax Highlighting, please use the ShikiHighlight component.
|
||||
* We could eventually use Shiki as a Markdown plugin, but I don't want to get into it right now.
|
||||
*/
|
||||
import { computed, Ref } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import MarkdownItClass from '@toycode/markdown-it-class'
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { useExternalLink } from '../gql-components/useExternalLink'
|
||||
import { mapValues, isArray, flatten } from 'lodash'
|
||||
|
||||
export interface UseMarkdownOptions {
|
||||
openExternal?: boolean
|
||||
classes?: {
|
||||
h1?: string[] | string
|
||||
h2?: string[] | string
|
||||
h3?: string[] | string
|
||||
h4?: string[] | string
|
||||
h5?: string[] | string
|
||||
h6?: string[] | string
|
||||
pre?: string[] | string
|
||||
p?: string[] | string
|
||||
a?: string[] | string
|
||||
ul?: string[] | string
|
||||
li?: string[] | string
|
||||
ol?: string[] | string
|
||||
code?: string[] | string
|
||||
}
|
||||
}
|
||||
|
||||
const defaultClasses = {
|
||||
h1: ['font-medium', 'text-4xl', 'mb-6'],
|
||||
h2: ['font-medium', 'text-3xl', 'mb-5'],
|
||||
h3: ['font-medium', 'text-2xl', 'mb-4'],
|
||||
h4: ['font-medium', 'text-1xl', 'mb-3'],
|
||||
h5: ['font-medium', 'text-sm', 'mb-3'],
|
||||
h6: ['font-medium', 'text-xs', 'mb-3'],
|
||||
p: ['my-3 first:mt-0 text-sm'],
|
||||
pre: ['rounded p-3'],
|
||||
code: [`font-medium rounded text-sm px-4px py-2px`],
|
||||
a: ['text-blue-500', 'hover:underline text-sm'],
|
||||
ul: ['list-disc pl-6 my-3 text-sm'],
|
||||
ol: ['list-decimal pl-6 my-3 text-sm'],
|
||||
}
|
||||
|
||||
const buildClasses = (options) => {
|
||||
// --- Normalize + Merge the classes ---
|
||||
// Array notation supports a single class or space-delimited classes.
|
||||
// Input: `['bg-pink text-pink-500', 'text-medium']`
|
||||
// Output: `[...defaults, 'bg-pink', 'text-pink-500', 'text-medium']`
|
||||
|
||||
// String notation is also supported and split by empty space
|
||||
// Input: `'bg-pink text-pink-500'`
|
||||
// Output: `[...defaults, 'bg-pink', text-pink-500']`
|
||||
|
||||
const _classes = defaultClasses // Constant above
|
||||
|
||||
const buildFlat = (value) => {
|
||||
if (isArray(value)) {
|
||||
return flatten<string>(value).map((arrValue) => arrValue.split(' '))
|
||||
}
|
||||
|
||||
return value?.split(' ') ?? []
|
||||
}
|
||||
|
||||
// Transform each value from defaultClasses and merge it with the user
|
||||
// input classes.
|
||||
if (options.classes) {
|
||||
return mapValues(_classes, (defaultValue, key) => {
|
||||
const inputClasses = buildFlat(options.classes[key])
|
||||
|
||||
return flatten([...buildFlat(defaultValue), ...inputClasses])
|
||||
})
|
||||
}
|
||||
|
||||
return _classes
|
||||
}
|
||||
|
||||
export const useMarkdown = (target: Ref<HTMLElement>, text: string, options: UseMarkdownOptions = {}) => {
|
||||
options.openExternal = options.openExternal || true
|
||||
|
||||
const classes = buildClasses(options)
|
||||
|
||||
const md = MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
highlight (str) {
|
||||
return `<pre class="${classes.pre.join(' ')}"><code>${str}</code></pre>`
|
||||
},
|
||||
})
|
||||
|
||||
md.use(MarkdownItClass, classes)
|
||||
|
||||
if (options.openExternal) {
|
||||
const open = useExternalLink()
|
||||
|
||||
whenever(target, () => {
|
||||
useEventListener(target, 'click', (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const url = (e.target as HTMLElement).getAttribute('href')
|
||||
|
||||
if (url) {
|
||||
open(url)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
markdown: computed(() => md.render(text, { sanitize: true })),
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
data-cy="external"
|
||||
:href="props.href"
|
||||
:use-default-hocus="props.useDefaultHocus"
|
||||
@click.prevent="openExternal"
|
||||
@click.prevent="open"
|
||||
><slot /></BaseLink>
|
||||
</template>
|
||||
|
||||
@@ -18,16 +18,7 @@ export default defineComponent({
|
||||
|
||||
<script setup lang="ts">
|
||||
import BaseLink from '../components/BaseLink.vue'
|
||||
import { ExternalLink_OpenExternalDocument } from '../generated/graphql'
|
||||
import { gql, useMutation } from '@urql/vue'
|
||||
|
||||
gql`
|
||||
mutation ExternalLink_OpenExternal ($url: String!) {
|
||||
openExternal(url: $url)
|
||||
}
|
||||
`
|
||||
|
||||
const openExternalMutation = useMutation(ExternalLink_OpenExternalDocument)
|
||||
import { useExternalLink } from '../gql-components/useExternalLink'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
href: string,
|
||||
@@ -36,7 +27,5 @@ const props = withDefaults(defineProps<{
|
||||
useDefaultHocus: true,
|
||||
})
|
||||
|
||||
const openExternal = () => {
|
||||
openExternalMutation.executeMutation({ url: props.href })
|
||||
}
|
||||
const open = useExternalLink(props.href)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ExternalLink_OpenExternalDocument } from '../generated/graphql'
|
||||
import { gql, useMutation } from '@urql/vue'
|
||||
import type { MaybeRef } from '@vueuse/core'
|
||||
import { unref } from 'vue'
|
||||
|
||||
gql`
|
||||
mutation ExternalLink_OpenExternal ($url: String!) {
|
||||
openExternal(url: $url)
|
||||
}
|
||||
`
|
||||
|
||||
export const useExternalLink = ($href?: MaybeRef<string>) => {
|
||||
const openExternalMutation = useMutation(ExternalLink_OpenExternalDocument)
|
||||
|
||||
return (href?: string) => {
|
||||
const resolvedHref = unref(typeof href === 'string' ? href : $href)
|
||||
|
||||
if (!resolvedHref) {
|
||||
return new Error(`Cannot open external link. Possible urls passed in were ${{ localHref: href, initialHref: unref($href) }}`)
|
||||
}
|
||||
|
||||
return openExternalMutation.executeMutation({ url: resolvedHref })
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@
|
||||
},
|
||||
"select": {
|
||||
"placeholder": "Choose an option..."
|
||||
},
|
||||
"alert": {
|
||||
"dismissAriaLabel": "Dismiss"
|
||||
}
|
||||
},
|
||||
"clipboard": {
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:class="props.class"
|
||||
@click.prevent="openExternal"
|
||||
v-html="compiledMarkdown"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import MarkdownItClass from '@toycode/markdown-it-class'
|
||||
import { gql, useMutation } from '@urql/vue'
|
||||
import { ExternalLink_OpenExternalDocument } from '../../generated/graphql'
|
||||
|
||||
gql`
|
||||
mutation ExternalLink_OpenExternal ($url: String!) {
|
||||
openExternal(url: $url)
|
||||
}
|
||||
`
|
||||
|
||||
const openExternalMutation = useMutation(ExternalLink_OpenExternalDocument)
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class: string,
|
||||
codeClass: string,
|
||||
text: string,
|
||||
}>(), {
|
||||
class: '',
|
||||
codeClass: '',
|
||||
})
|
||||
|
||||
const md = MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
highlight (str) {
|
||||
return `<pre class="border rounded-sm p-3 ${props.codeClass}"><code>${str}</code></pre>`
|
||||
},
|
||||
})
|
||||
|
||||
// defines classes to apply to elements that may be in the rendered markdown
|
||||
// <pre> is handled above in the `highlight` method
|
||||
md.use(MarkdownItClass, {
|
||||
h1: ['font-medium', 'text-4xl', 'mb-6'],
|
||||
h2: ['font-medium', 'text-3xl', 'mb-5'],
|
||||
h3: ['font-medium', 'text-2xl', 'mb-4'],
|
||||
h4: ['font-medium', 'text-1xl', 'mb-3'],
|
||||
h5: ['font-medium', 'text-sm', 'mb-3'],
|
||||
h6: ['font-medium', 'text-xs', 'mb-3'],
|
||||
p: ['my-3 first:mt-0'],
|
||||
code: [`border rounded-sm px-2px ${props.codeClass}`],
|
||||
a: ['text-blue-500', 'hover:underline'],
|
||||
ul: ['list-disc pl-6 my-3'],
|
||||
ol: ['list-decimal pl-6 my-3'],
|
||||
})
|
||||
|
||||
const compiledMarkdown = computed(() => md.render(props.text, { sanitize: true }))
|
||||
const openExternal = (e) => {
|
||||
const url = e.target?.href
|
||||
|
||||
if (url) {
|
||||
openExternalMutation.executeMutation({ url })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,27 +1,49 @@
|
||||
import { defaultMessages } from '@cy/i18n'
|
||||
import Warning from './Warning.vue'
|
||||
import faker from 'faker'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const title = faker.hacker.noun()
|
||||
const message = `
|
||||
# Hello!
|
||||
> This is a **markdown formatted** message!
|
||||
|
||||
We're going to print out some \`console.log('cool code')\` and see how well it formats inside of our warning.
|
||||
`
|
||||
|
||||
describe('<Warning />', () => {
|
||||
it('renders with title and message', () => {
|
||||
cy.mount(() => (<div class="p-4"><Warning
|
||||
title="The title"
|
||||
message="The message"
|
||||
dismiss={() => {}}
|
||||
data-testid="warning"
|
||||
title={title}
|
||||
message={message}
|
||||
/></div>))
|
||||
|
||||
cy.contains('The title')
|
||||
cy.contains('The message')
|
||||
cy.contains(title)
|
||||
cy.get('[data-testid=warning]')
|
||||
.should('contain.text', 'Hello!')
|
||||
.and('contain.text', 'This is a markdown formatted message!')
|
||||
.and('contain.text', `We're going to print out some console.log('cool code') and see how well it formats inside of our warning.`)
|
||||
})
|
||||
|
||||
it('calls dismiss when X is clicked', () => {
|
||||
const onDismiss = cy.stub()
|
||||
const show = ref(true)
|
||||
const onUpdate = cy.spy()
|
||||
const methods = {
|
||||
'onUpdate:modelValue': (value) => {
|
||||
show.value = value
|
||||
onUpdate()
|
||||
},
|
||||
}
|
||||
|
||||
cy.mount(() => (<div class="p-4"><Warning
|
||||
title="The title"
|
||||
message="The message"
|
||||
dismiss={onDismiss}
|
||||
title={title}
|
||||
message={message}
|
||||
modelValue={show.value}
|
||||
{...methods}
|
||||
/></div>))
|
||||
|
||||
cy.get('[data-cy=dismiss]').click()
|
||||
cy.wrap(onDismiss).should('be.called')
|
||||
cy.get(`[aria-label=${defaultMessages.components.alert.dismissAriaLabel}`).first().click()
|
||||
cy.wrap(onUpdate).should('be.called')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
<template>
|
||||
<div
|
||||
data-cy="warning"
|
||||
class="rounded min-w-200px bg-warning-100 mb-5"
|
||||
<Alert
|
||||
v-model="show"
|
||||
dismissible
|
||||
status="warning"
|
||||
data-testid="warning-alert"
|
||||
header-class="text-warning-600"
|
||||
:title="title"
|
||||
:icon="ErrorOutlineIcon"
|
||||
>
|
||||
<h3 class="text-lg text-warning-600 font-medium flex items-center p-5 relative">
|
||||
<i-cy-status-errored-outline_x16 class="w-24px h-24px mr-2" /> {{ props.title }}
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
data-cy="dismiss"
|
||||
class="text-warning-500 hocus:text-warning-600 outline-none absolute w-32px
|
||||
h-32px right-8px top-8px flex items-center justify-center"
|
||||
@click="onDismiss"
|
||||
>
|
||||
<i-cy-delete_x12 class="icon-dark-current w-12px h-12px" />
|
||||
</button>
|
||||
</h3>
|
||||
<Markdown
|
||||
class="border-t border-warning-200 text-warning-500 py-5 mt-0 mx-5"
|
||||
code-class="bg-white bg-opacity-25 border-warning-300"
|
||||
:text="props.message"
|
||||
<div
|
||||
ref="markdownTarget"
|
||||
v-html="markdown"
|
||||
/>
|
||||
</div>
|
||||
</Alert>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Markdown from '../components/markdown/Markdown.vue'
|
||||
import ErrorOutlineIcon from '~icons/cy/status-errored-outline_x16.svg'
|
||||
import { useMarkdown } from '@packages/frontend-shared/src/composables/useMarkdown'
|
||||
import Alert from '@cy/components/Alert.vue'
|
||||
import { ref } from 'vue'
|
||||
import { useVModels } from '@vueuse/core'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string,
|
||||
message: string,
|
||||
dismiss: Function,
|
||||
const emits = defineEmits<{
|
||||
(eventName: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const onDismiss = () => {
|
||||
props.dismiss()
|
||||
}
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string,
|
||||
message: string,
|
||||
modelValue: boolean
|
||||
}>(), { modelValue: true })
|
||||
|
||||
const { modelValue: show } = useVModels(props, emits)
|
||||
const markdownTarget = ref()
|
||||
const { markdown } = useMarkdown(markdownTarget, props.message, { classes: { code: ['bg-warning-200'] } })
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { WarningListFragmentDoc } from '../generated/graphql-test'
|
||||
import WarningList from './WarningList.vue'
|
||||
import faker from 'faker'
|
||||
import { defaultMessages } from '@cy/i18n'
|
||||
|
||||
const createWarning = (props = {}) => Object.assign({
|
||||
const warningSelector = '[data-testid=warning-alert]'
|
||||
const message = faker.hacker.phrase()
|
||||
const title = faker.hacker.ingverb()
|
||||
|
||||
const createWarning = (props = {}) => ({
|
||||
__typename: 'Warning',
|
||||
title: 'Warning title',
|
||||
message: 'Warning message',
|
||||
title,
|
||||
message,
|
||||
setupStep: 'welcome',
|
||||
}, props)
|
||||
...props,
|
||||
})
|
||||
|
||||
const firstWarning = createWarning({ title: faker.hacker.ingverb(), message: faker.hacker.phrase(), setupStep: null })
|
||||
const secondWarning = createWarning({ title: faker.hacker.ingverb(), message: faker.hacker.phrase(), setupStep: null })
|
||||
|
||||
describe('<WarningList />', () => {
|
||||
it('does not render warning if there are none', () => {
|
||||
@@ -17,7 +27,7 @@ describe('<WarningList />', () => {
|
||||
render: (gqlVal) => <div class="p-4"><WarningList gql={gqlVal} /></div>,
|
||||
})
|
||||
|
||||
cy.get('[data-cy=warning]').should('not.exist')
|
||||
cy.get(warningSelector).should('not.exist')
|
||||
})
|
||||
|
||||
it('does not render warning if on different step', () => {
|
||||
@@ -30,7 +40,7 @@ describe('<WarningList />', () => {
|
||||
render: (gqlVal) => <div class="p-4"><WarningList gql={gqlVal} /></div>,
|
||||
})
|
||||
|
||||
cy.contains('Warning message').should('not.exist')
|
||||
cy.contains(message).should('not.exist')
|
||||
})
|
||||
|
||||
it('renders warning if on same step', () => {
|
||||
@@ -45,7 +55,7 @@ describe('<WarningList />', () => {
|
||||
render: (gqlVal) => <div class="p-4"><WarningList gql={gqlVal} /></div>,
|
||||
})
|
||||
|
||||
cy.contains('Warning message').should('be.visible')
|
||||
cy.contains(message).should('be.visible')
|
||||
})
|
||||
|
||||
it('renders warning if no step specified', () => {
|
||||
@@ -60,7 +70,7 @@ describe('<WarningList />', () => {
|
||||
render: (gqlVal) => <div class="p-4"><WarningList gql={gqlVal} /></div>,
|
||||
})
|
||||
|
||||
cy.contains('Warning message').should('be.visible')
|
||||
cy.contains(message).should('be.visible')
|
||||
})
|
||||
|
||||
it('renders multiple warnings', () => {
|
||||
@@ -68,21 +78,12 @@ describe('<WarningList />', () => {
|
||||
onResult (result) {
|
||||
result.step = 'setupComplete'
|
||||
// @ts-ignore
|
||||
result.warnings = [createWarning({
|
||||
title: 'Warning title 1',
|
||||
message: 'Warning message 1',
|
||||
setupStep: null,
|
||||
// @ts-ignore
|
||||
}), createWarning({
|
||||
title: 'Warning title 2',
|
||||
message: 'Warning message 2',
|
||||
setupStep: null,
|
||||
})]
|
||||
result.warnings = [firstWarning, secondWarning]
|
||||
},
|
||||
render: (gqlVal) => <div class="p-4"><WarningList gql={gqlVal} /></div>,
|
||||
})
|
||||
|
||||
cy.get('[data-cy=warning]').should('have.length', 2)
|
||||
cy.get(warningSelector).should('have.length', 2)
|
||||
})
|
||||
|
||||
it('removes warning when dismissed', () => {
|
||||
@@ -90,24 +91,17 @@ describe('<WarningList />', () => {
|
||||
onResult (result) {
|
||||
result.step = 'setupComplete'
|
||||
// @ts-ignore
|
||||
result.warnings = [createWarning({
|
||||
title: 'Warning title 1',
|
||||
message: 'Warning message 1',
|
||||
setupStep: null,
|
||||
// @ts-ignore
|
||||
}), createWarning({
|
||||
title: 'Warning title 2',
|
||||
message: 'Warning message 2',
|
||||
setupStep: null,
|
||||
})]
|
||||
result.warnings = [firstWarning, secondWarning]
|
||||
},
|
||||
render: (gqlVal) => <div class="p-4"><WarningList gql={gqlVal} /></div>,
|
||||
})
|
||||
|
||||
cy.get('[data-cy=warning]').should('have.length', 2)
|
||||
cy.contains('Warning message 1')
|
||||
cy.get('[data-cy=dismiss]').first().click()
|
||||
cy.get('[data-cy=warning]').should('have.length', 1)
|
||||
cy.contains('Warning message 1').should('not.exist')
|
||||
cy.get(warningSelector).should('have.length', 2)
|
||||
cy.contains(firstWarning.message)
|
||||
|
||||
// @ts-ignore
|
||||
cy.findAllByLabelText(defaultMessages.components.modal.dismiss).first().click()
|
||||
cy.get(warningSelector).should('have.length', 1)
|
||||
cy.contains(firstWarning.message).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<template>
|
||||
<Warning
|
||||
v-for="warning in warnings"
|
||||
:key="warningKey(warning)"
|
||||
:key="warning.key"
|
||||
:title="warning.title"
|
||||
:message="warning.message"
|
||||
:dismiss="onDismiss(warning)"
|
||||
dismissible
|
||||
@update:modelValue="dismiss(warning.key)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { gql } from '@urql/core'
|
||||
import type { WarningListFragment } from '../generated/graphql'
|
||||
import Warning from '../warning/Warning.vue'
|
||||
@@ -28,25 +29,20 @@ const props = defineProps<{
|
||||
gql: WarningListFragment
|
||||
}>()
|
||||
|
||||
const dismissed = reactive({})
|
||||
|
||||
const warningKey = (warning) => `${warning.title}${warning.message}`
|
||||
|
||||
const onDismiss = (warning) => {
|
||||
return () => {
|
||||
dismissed[warningKey(warning)] = true
|
||||
}
|
||||
}
|
||||
|
||||
const dismissed = ref({})
|
||||
const warnings = computed(() => {
|
||||
return props.gql.warnings.filter((warning) => {
|
||||
return (
|
||||
!dismissed[warningKey(warning)]
|
||||
&& (
|
||||
!warning.setupStep
|
||||
|| warning.setupStep === props.gql.step
|
||||
)
|
||||
)
|
||||
return props.gql.warnings
|
||||
.map((w) => ({ ...w, key: `${w.title}${w.message}` }))
|
||||
.filter((warning) => {
|
||||
const hasBeenDismissed = dismissed.value[warning.key]
|
||||
|
||||
return !hasBeenDismissed && !warning.setupStep || warning.setupStep === props.gql.step
|
||||
})
|
||||
})
|
||||
|
||||
const dismiss = (key) => {
|
||||
// TODO, call a mutation here so that the server persists the result of the mutation.
|
||||
// However, we still intend to keep the "warnings" dismissal so that the client updates immediately before the server responds.
|
||||
dismissed.value[key] = true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/node_modules/@toycode/markdown-it-class/index.js b/node_modules/@toycode/markdown-it-class/index.js
|
||||
index 8e8c8f4..5e2539d 100644
|
||||
--- a/node_modules/@toycode/markdown-it-class/index.js
|
||||
+++ b/node_modules/@toycode/markdown-it-class/index.js
|
||||
@@ -8,7 +8,7 @@ const toArray = a => (Array.isArray(a) ? a : [a])
|
||||
|
||||
function parseTokens(tokens) {
|
||||
tokens.forEach(token => {
|
||||
- if (/(_open$|image)/.test(token.type) && mapping[token.tag]) {
|
||||
+ if (/(_open$|image|code_inline)/.test(token.type) && mapping[token.tag]) {
|
||||
const orig = splitWithSpace(token.attrGet('class'))
|
||||
const addition = toArray(mapping[token.tag])
|
||||
token.attrSet('class', [...orig, ...addition].join(' '))
|
||||
Reference in New Issue
Block a user