Browse Source

添加XForm模块

main
dark 1 month ago
parent
commit
7079054c8c
  1. 13
      src/components/table/Table.tsx
  2. 8
      src/pages/db/vod/index.tsx
  3. 6
      src/pages/x-form/hooks/useApi.tsx
  4. 61
      src/pages/x-form/index.tsx
  5. 15
      src/pages/x-form/style.ts
  6. 146
      src/pages/x-form/utils/index.tsx
  7. 54
      src/service/base.ts
  8. 12
      src/service/x-form/model.ts
  9. 45
      src/store/x-form/model.ts
  10. 20
      src/types/x-form/model.d.ts
  11. 14
      src/utils/index.ts

13
src/components/table/Table.tsx

@ -1,13 +1,18 @@
import { ProTable, ProTableProps, ProCard } from '@ant-design/pro-components' import { ProTable, ProTableProps, ProCard } from '@ant-design/pro-components'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useStyle } from './style' import { useStyle } from './style'
import { Pagination } from 'antd'
import { Pagination, Select as AntSelect, SelectProps } from 'antd'
import { t } from '@/i18n.ts' import { t } from '@/i18n.ts'
export interface TableProps<T = any, D = any> extends ProTableProps<T, D> { export interface TableProps<T = any, D = any> extends ProTableProps<T, D> {
} }
const Select = (props: SelectProps) => {
return <AntSelect {...props} size={'small'} getPopupContainer={() => document.body}/>
}
Select.Option = AntSelect.Option
export const Table = <T extends Record<string, any> = any, D = any>(props: TableProps<T, D>) => { export const Table = <T extends Record<string, any> = any, D = any>(props: TableProps<T, D>) => {
const { styles } = useStyle() const { styles } = useStyle()
@ -102,9 +107,13 @@ export const Table = <T extends Record<string, any> = any, D = any>(props: Table
<Pagination size={'small'} <Pagination size={'small'}
{...pagination} {...pagination}
className={styles.pagination} className={styles.pagination}
selectComponentClass={
Select
}
showTotal={(total: number, range: [ number, number ]) => `${t('pagination.total.range', '第')} ${range[0]}-${range[1]} ${t('pagination.total.total', '条/总共')} ${total} ${t('pagination.total.item', '条')}`} showTotal={(total: number, range: [ number, number ]) => `${t('pagination.total.range', '第')} ${range[0]}-${range[1]} ${t('pagination.total.total', '条/总共')} ${total} ${t('pagination.total.item', '条')}`}
showQuickJumper={true} showQuickJumper={true}
showSizeChanger={true}/>
showSizeChanger={true}
/>
</div> </div>
</ProCard> </ProCard>
}} }}

8
src/pages/db/vod/index.tsx

@ -1,5 +1,5 @@
import { t, useTranslation } from '@/i18n.ts'
import { Button, Form, Popconfirm, Divider, Space, Tooltip, Badge, Layout, Menu, Spin, Select } from 'antd'
import { useTranslation } from '@/i18n.ts'
import { Button, Form, Popconfirm, Divider, Space, Tooltip, Badge,Select } from 'antd'
import { useAtom, useAtomValue } from 'jotai' import { useAtom, useAtomValue } from 'jotai'
import { import {
deleteVodAtom, deleteVodAtom,
@ -9,12 +9,12 @@ import { useEffect, useMemo, useState } from 'react'
import Action from '@/components/action/Action.tsx' import Action from '@/components/action/Action.tsx'
import { import {
BetaSchemaForm, BetaSchemaForm,
ProColumns, ProForm,
ProColumns,
ProFormColumnsType, ProFormColumnsType,
} from '@ant-design/pro-components' } from '@ant-design/pro-components'
import ListPageLayout from '@/layout/ListPageLayout.tsx' import ListPageLayout from '@/layout/ListPageLayout.tsx'
import { useStyle } from './style' import { useStyle } from './style'
import { ExportOutlined, FilterOutlined } from '@ant-design/icons'
import { FilterOutlined } from '@ant-design/icons'
import { genProTableColumnWidthProps, getValueCount } from '@/utils' import { genProTableColumnWidthProps, getValueCount } from '@/utils'
import { Table as ProTable } from '@/components/table' import { Table as ProTable } from '@/components/table'
import InteractPopup from '@/components/interact-popup' import InteractPopup from '@/components/interact-popup'

6
src/pages/x-form/hooks/useApi.tsx

@ -10,9 +10,10 @@ export const useApi = () => {
const nav = useNavigate() const nav = useNavigate()
const [ api, setApi ] = useAtom(apiAtom) const [ api, setApi ] = useAtom(apiAtom)
const { api: apiParam } = Route.useSearch() const { api: apiParam } = Route.useSearch()
const [ isChange, setChange ] = useState(false)
const [ innerApi, setInnerApi ] = useState('') const [ innerApi, setInnerApi ] = useState('')
const [ open, setOpen ] = useState(false) const [ open, setOpen ] = useState(false)
const apiRef = useRef<string>()
const apiRef = useRef<string>(apiParam)
useEffect(() => { useEffect(() => {
@ -60,8 +61,10 @@ export const useApi = () => {
message.error('请填写 api 参数') message.error('请填写 api 参数')
return return
} }
setChange(false)
setOpen(false) setOpen(false)
setApi(innerApi) setApi(innerApi)
setChange(true)
nav({ nav({
to: '/x-form', to: '/x-form',
search: { search: {
@ -80,6 +83,7 @@ export const useApi = () => {
holderElement, holderElement,
updateApi: setOpen, updateApi: setOpen,
setApi, setApi,
apiChange: isChange,
api, api,
} as const } as const

61
src/pages/x-form/index.tsx

@ -1,9 +1,10 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import ListPageLayout from '@/layout/ListPageLayout.tsx' import ListPageLayout from '@/layout/ListPageLayout.tsx'
import { useApi } from './hooks/useApi.tsx' import { useApi } from './hooks/useApi.tsx'
import { Badge, Button, Divider, Form, Popconfirm, Space, Tabs, Tag, Tooltip } from 'antd'
import { Badge, Button, Divider, Form, Popconfirm, Space, Tag, Tooltip } from 'antd'
import { EditOutlined, FilterOutlined } from '@ant-design/icons' import { EditOutlined, FilterOutlined } from '@ant-design/icons'
import { BetaSchemaForm, ProColumns, ProFormColumnsType, ProTable } from '@ant-design/pro-components'
import { BetaSchemaForm, ProFormColumnsType } from '@ant-design/pro-components'
import { Table as ProTable } from '@/components/table'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import { import {
@ -17,11 +18,12 @@ import { useAtom } from 'jotai/index'
import { getValueCount, unSetColumnRules } from '@/utils' import { getValueCount, unSetColumnRules } from '@/utils'
import { useStyle } from './style.ts' import { useStyle } from './style.ts'
import Action from '@/components/action/Action.tsx' import Action from '@/components/action/Action.tsx'
import { transformAntdTableProColumns } from './utils'
const XForm = () => { const XForm = () => {
const { styles, cx } = useStyle() const { styles, cx } = useStyle()
const { holderElement, updateApi, api } = useApi()
const { holderElement, updateApi, api, apiChange } = useApi()
const [ form ] = Form.useForm() const [ form ] = Form.useForm()
const [ filterForm ] = Form.useForm() const [ filterForm ] = Form.useForm()
@ -30,23 +32,13 @@ const XForm = () => {
const [ search, setSearch ] = useAtom(modelSearchAtom) const [ search, setSearch ] = useAtom(modelSearchAtom)
const { data, isFetching, isLoading, refetch } = useAtomValue(modelsAtom) const { data, isFetching, isLoading, refetch } = useAtomValue(modelsAtom)
const { mutate: deleteModel, isPending: isDeleting } = useAtomValue(deleteModelAtom) const { mutate: deleteModel, isPending: isDeleting } = useAtomValue(deleteModelAtom)
const { data: curdModal, isLoading: curdLoading } = useAtomValue(modelCURDAtom)
const { data: curdModal, isLoading: curdLoading, refetch: reloadCURDModal } = useAtomValue(modelCURDAtom)
const [ open, setOpen ] = useState(false) const [ open, setOpen ] = useState(false)
const [ openFilter, setFilterOpen ] = useState(false) const [ openFilter, setFilterOpen ] = useState(false)
const [ searchKey, setSearchKey ] = useState(search?.key) const [ searchKey, setSearchKey ] = useState(search?.key)
const columns = useMemo(() => { const columns = useMemo(() => {
return (curdModal?.column?.map((item) => {
return {
title: item.label,
dataIndex: item.prop,
key: item.prop,
valueType: item.type,
hideInSearch: !item.search,
hideInTable: item.hide,
} as ProColumns<any>
}) || []).concat([
return transformAntdTableProColumns(curdModal?.column || []).concat([
{ {
title: 'ID', title: 'ID',
dataIndex: 'id', dataIndex: 'id',
@ -95,6 +87,25 @@ const XForm = () => {
} }
}, [ isSuccess ]) }, [ isSuccess ])
useEffect(() => {
console.log('apiChange')
if (apiChange) {
reloadCURDModal()
}
}, [ apiChange ])
const formProps = curdModal?.dialogType === 'drawer' ? {
layoutType: 'DrawerForm',
drawerProps: {
maskClosable: false,
}
} : {
layoutType: 'ModalForm',
modalProps: {
maskClosable: false,
}
}
return ( return (
<> <>
{holderElement} {holderElement}
@ -113,7 +124,7 @@ const XForm = () => {
rowKey="id" rowKey="id"
headerTitle={api} headerTitle={api}
toolbar={{ toolbar={{
search: {
/*search: {
loading: isFetching && !!search?.key, loading: isFetching && !!search?.key,
onSearch: (value: string) => { onSearch: (value: string) => {
setSearch(prev => ({ setSearch(prev => ({
@ -127,7 +138,7 @@ const XForm = () => {
}, },
value: searchKey, value: searchKey,
placeholder: '输入关键字搜索', placeholder: '输入关键字搜索',
},
},*/
actions: [ actions: [
<Tooltip key={'filter'} title={'高级查询'}> <Tooltip key={'filter'} title={'高级查询'}>
<Badge count={getValueCount(search)}> <Badge count={getValueCount(search)}>
@ -151,7 +162,7 @@ const XForm = () => {
] ]
}} }}
scroll={{ scroll={{
// x: 3500,
x: (columns?.length || 1) * 200,
y: 'calc(100vh - 290px)' y: 'calc(100vh - 290px)'
}} }}
search={false} search={false}
@ -205,16 +216,12 @@ const XForm = () => {
layout={'vertical'} layout={'vertical'}
scrollToFirstError={true} scrollToFirstError={true}
title={form.getFieldValue('id') !== 0 ? '编辑' : '添加'} title={form.getFieldValue('id') !== 0 ? '编辑' : '添加'}
layoutType={'DrawerForm'}
{...formProps as any}
open={open} open={open}
drawerProps={{
maskClosable: false,
}}
onOpenChange={(open) => { onOpenChange={(open) => {
setOpen(open) setOpen(open)
}} }}
loading={isSubmitting} loading={isSubmitting}
onFinish={async (values) => { onFinish={async (values) => {
saveOrUpdate(values) saveOrUpdate(values)
}} }}
@ -231,9 +238,13 @@ const XForm = () => {
}} }}
layout={'vertical'} layout={'vertical'}
scrollToFirstError={true} scrollToFirstError={true}
layoutType={'DrawerForm'}
layoutType={formProps.layoutType as any}
drawerProps={{ drawerProps={{
maskClosable: false,
...formProps.drawerProps,
mask: false,
}}
modalProps={{
...formProps.modalProps,
mask: false, mask: false,
}} }}
submitter={{ submitter={{

15
src/pages/x-form/style.ts

@ -5,7 +5,22 @@ export const useStyle = createStyles(({ token, css, cx, prefixCls }, props: any)
const prefix = `${prefixCls}-${token?.proPrefix}-x-form-page` const prefix = `${prefixCls}-${token?.proPrefix}-x-form-page`
const container = css` const container = css`
.ant-table-cell {
.ant-tag {
padding-inline: 3px;
margin-inline-end: 3px;
}
}
.ant-table-empty {
.ant-table-body {
height: calc(100vh - 350px)
}
}
.ant-pro-table-highlight {
}
` `
return { return {

146
src/pages/x-form/utils/index.tsx

@ -0,0 +1,146 @@
import { XForm } from '@/types/x-form/model'
import { ProColumns } from '@ant-design/pro-components'
import Switch from '@/components/switch'
import { Checkbox, DatePicker, Input, Radio, Select, TreeSelect } from 'antd'
import request from '@/request'
import { convertToBool, genProTableColumnWidthProps } from '@/utils'
import { ReactNode } from 'react'
const getValueType = (column: XForm.IColumn) => {
switch (column.type) {
case 'input':
return 'text'
case 'select':
return 'select'
case 'date':
return 'date'
case 'switch':
return 'switch'
case 'radio':
return 'radio'
case 'checkbox':
return 'checkbox'
case 'textarea':
return 'textarea'
case 'tree':
return 'treeSelect'
default:
return 'text'
}
}
//根据type返回对应的组件
const getComponent = (column: XForm.IColumn) => {
const type = getValueType(column) as any
switch (type) {
case 'input':
return Input
case 'select':
return Select
case 'date':
return DatePicker
case 'switch':
return Switch
case 'radio':
return Radio
case 'checkbox':
return Checkbox
case 'textarea':
return Input.TextArea
case 'tree':
case 'treeSelect':
return TreeSelect
default:
return Input
}
}
export const transformAntdTableProColumns = (columns: XForm.IColumn[]) => {
return (columns || []).map(item => {
const { value, props, multiple, checkStrictly } = item
const { width, fieldProps: _fieldProps } = genProTableColumnWidthProps(item.width)
const fieldProps: ProColumns['fieldProps'] = {
dataFiledNames: props,
...(multiple ? { multiple: true } : {}),
...(checkStrictly ? { treeCheckStrictly: true } : {}),
..._fieldProps,
}
const formItemProps: ProColumns['formItemProps'] = (form, config) => {
return {
rules: item.rules?.map(i => {
return {
required: i.required,
message: i.message
}
}),
...(value ? { valuePropName: value } : {})
}
}
const rowProps = item.gutter ? { gutter: item.gutter } : { gutter: [ 16, 0 ], }
const colProps = item.span ? { span: item.span } : {}
const type = getValueType(item)
return {
title: item.label,
dataIndex: item.prop,
key: item.prop,
width,
valueType: type,
hideInSearch: !item.search,
hideInTable: item.hide,
fieldProps,
formItemProps,
colProps,
rowProps,
request: item.dicUrl ? async (params, props) => {
const { fieldProps: { dataFiledNames } } = props
const { value, res: resKey, label } = dataFiledNames || {}
const url = `/${item.dicUrl.replace(/^:/, '/')}`
return request[item.dicMethod || 'get'](url, params).then(res => {
return (res.data?.[resKey] || res.data || []).map((i: any) => {
// console.log(i)
const disabled = 'disabled' in i ? i.disabled :
('status' in i ? !convertToBool(i.status) : false)
return {
title: i[label || 'label'],
label: i[label || 'label'],
value: i[value || 'id'],
disabled,
data: i
}
})
})
} : undefined,
renderFormItem: (_scheam, config) => {
const Component = getComponent(item) as any
const { options, ...props } = config as any
if ([ 'tree', 'treeSelect' ].includes(_scheam.valueType as string)) {
return <Component {...props} treeData={options}/>
}
if (_scheam.valueType as string === 'select') {
return <Select {...props} options={options}/>
}
return <Component {...config} />
},
render: (text: any, record: any) => {
if (type === 'switch' || type === 'checkbox' || type === 'radio') {
return <Switch size={'small'} value={record[item.prop]}/>
}
if (item.colorFormat) {
return <span style={{ color: item.colorFormat }}>{text}</span>
}
return text
}
} as ProColumns
})
}

54
src/service/base.ts

@ -6,26 +6,52 @@ import { XForm } from '@/types/x-form/model'
export const createCURD = <TParams, TResult>(api: string, options?: AxiosRequestConfig) => { export const createCURD = <TParams, TResult>(api: string, options?: AxiosRequestConfig) => {
return { return {
list: (params?: TParams & IPage) => {
return request.post<IPageResult<TResult>>(`${api}/list`, { ...params }, options)
list: (params?: TParams & IPage, opt?: AxiosRequestConfig) => {
const method = opt?.method || 'post'
return request[method]<IPageResult<TResult>>(`${api}/list`, { ...params }, {
...options,
...opt
})
}, },
add: (data: TParams) => {
return request.post<TResult>(`${api}/add`, { ...data }, options)
add: (data: TParams, opt?: AxiosRequestConfig) => {
return request.post<TResult>(`${api}/add`, { ...data }, {
...options,
...opt
})
}, },
update: (data: TParams) => {
return request.post<TResult>(`${api}/edit`, { ...data }, options)
update: (data: TParams, opt?: AxiosRequestConfig) => {
return request.post<TResult>(`${api}/edit`, { ...data }, {
...options,
...opt
})
}, },
delete: (id: number) => {
return request.post<TResult>(`${api}/delete`, { id }, options)
delete: (id: number, opt?: AxiosRequestConfig) => {
const method = opt?.method || 'post'
return request[method]<TResult>(`${api}/delete`, { id }, {
...options,
...opt
})
}, },
batchDelete: (ids: number[]) => {
return request.post<TResult>(`${api}/deletes`, { ids }, options)
batchDelete: (ids: number[], opt?: AxiosRequestConfig) => {
const method = opt?.method || 'post'
return request[method]<TResult>(`${api}/deletes`, { ids }, {
...options,
...opt
})
}, },
info: (id: number) => {
return request.post<TResult>(`${api}/get`, { id }, options)
info: (id: number, opt?: AxiosRequestConfig) => {
const method = opt?.method || 'post'
return request[method]<TResult>(`${api}/get`, { id }, {
...options,
...opt
})
}, },
curd: async (params: any) => {
return request.post<XForm.IModelCURD>(`${api}/ui/curd`, { ...params })
curd: async (params: any, opt?: AxiosRequestConfig) => {
const method = opt?.method || 'post'
return request[method]<XForm.IModelCURD>(`${api}/ui/curd`, { ...params }, {
...options,
...opt
})
} }
} }

12
src/service/x-form/model.ts

@ -1,9 +1,21 @@
import { createCURD } from '@/service/base.ts' import { createCURD } from '@/service/base.ts'
import { XForm } from '@/types/x-form/model' import { XForm } from '@/types/x-form/model'
import request from '@/request.ts'
const model = (api: string) => { const model = (api: string) => {
return { return {
...createCURD<any, XForm.IModel>(api), ...createCURD<any, XForm.IModel>(api),
proxy: async <T = XForm.IModelCURD>(params?: {
path: string,
body: any,
method?: string
}) => {
return request.post<T>(`/x_form/proxy`, {
api: `${api}/ui/curd`,
method: 'post',
...params
})
}
} }
} }

45
src/store/x-form/model.ts

@ -1,5 +1,5 @@
import { atom } from 'jotai' import { atom } from 'jotai'
import { IApiResult, IError, IPage } from '@/global'
import { IApiResult, IPage } from '@/global'
import { atomWithMutation, atomWithQuery, queryClientAtom } from 'jotai-tanstack-query' import { atomWithMutation, atomWithQuery, queryClientAtom } from 'jotai-tanstack-query'
import { message } from 'antd' import { message } from 'antd'
import { t } from 'i18next' import { t } from 'i18next'
@ -32,21 +32,16 @@ export const modelPageAtom = atom<IPage>({
page: 1, page: 1,
}) })
export const modelCURDAtom = atomWithQuery((get) => {
export const modelCURDAtom = atomWithQuery<IApiResult<XForm.IModelCURD>, any, any>((get) => {
const api = get(apiAtom) const api = get(apiAtom)
console.log('api', api)
return { return {
enabled: !!api, enabled: !!api,
queryKey: [ 'modelCURD', get(modelSearchAtom) ],
queryFn: async ({ queryKey: [ , params ] }) => {
// if (!api) {
// return Promise.reject({
// code: 400,
// message: 'api 不能为空'
// })
// }
return await modelServ.model(api).curd(params as SearchParams)
queryKey: [ 'modelCURD' ],
queryFn: async () => {
return await modelServ.model(api).proxy()
}, },
select: res => {
select: (res) => {
return res.data.data return res.data.data
} }
} }
@ -60,6 +55,13 @@ export const modelsAtom = atomWithQuery((get) => {
enabled: curd.isSuccess && !!api, enabled: curd.isSuccess && !!api,
queryKey: [ 'models', get(modelSearchAtom) ], queryKey: [ 'models', get(modelSearchAtom) ],
queryFn: async ({ queryKey: [ , params ] }) => { queryFn: async ({ queryKey: [ , params ] }) => {
if (api.startsWith('http')) {
return await modelServ.model(api).proxy<XForm.IModel>({
path: '/list',
body: params,
})
}
return await modelServ.model(api).list(params as SearchParams) return await modelServ.model(api).list(params as SearchParams)
}, },
select: res => { select: res => {
@ -86,8 +88,20 @@ export const saveOrUpdateModelAtom = atomWithMutation<IApiResult, XForm.IModel>(
return Promise.reject('api 不能为空') return Promise.reject('api 不能为空')
} }
if (data.id === 0) { if (data.id === 0) {
if (api.startsWith('http')) {
return await modelServ.model(api).proxy<XForm.IModel>({
body: data,
path: '/add',
})
}
return await modelServ.model(api).add(data) return await modelServ.model(api).add(data)
} }
if (api.startsWith('http')) {
return await modelServ.model(api).proxy<XForm.IModel>({
body: data,
path: '/edit',
})
}
return await modelServ.model(api).update(data) return await modelServ.model(api).update(data)
}, },
onSuccess: (res) => { onSuccess: (res) => {
@ -109,8 +123,11 @@ export const deleteModelAtom = atomWithMutation((get) => {
mutationKey: [ 'deleteModel' ], mutationKey: [ 'deleteModel' ],
mutationFn: async (ids: number[]) => { mutationFn: async (ids: number[]) => {
const api = get(apiAtom) const api = get(apiAtom)
if (!api) {
return Promise.reject('api 不能为空')
if (api.startsWith('http')) {
return await modelServ.model(api).proxy<XForm.IModel>({
body: ids ?? get(modelIdsAtom) ,
path: '/deletes',
})
} }
return await modelServ.model(api).batchDelete(ids ?? get(modelIdsAtom)) return await modelServ.model(api).batchDelete(ids ?? get(modelIdsAtom))
}, },

20
src/types/x-form/model.d.ts

@ -9,15 +9,17 @@ export namespace XForm {
export interface IModelCURD { export interface IModelCURD {
height: string;
calcHeight: number;
dialogType: string;
column: IColumn[];
searchMenuSpan: number;
searchIndex: number;
searchIcon: boolean;
[key: string]: any;
data: {
height: string;
calcHeight: number;
dialogType: string;
column: IColumn[];
searchMenuSpan: number;
searchIndex: number;
searchIcon: boolean;
[key: string]: any;
}
} }
export interface IColumn { export interface IColumn {

14
src/utils/index.ts

@ -122,6 +122,7 @@ export const convertToBool = (value: any): boolean => {
if (typeof value === 'string') { if (typeof value === 'string') {
switch (value.toLowerCase()) { switch (value.toLowerCase()) {
case '0': case '0':
case '':
return false return false
case 'true': case 'true':
return true return true
@ -177,8 +178,18 @@ export const getValueCount = (obj: any, filterObj: any = {}) => {
export const unSetColumnRules = (columns: any[]) => { export const unSetColumnRules = (columns: any[]) => {
return columns.map(col => { return columns.map(col => {
col.__ignoreRules = true
if (col.formItemProps?.rules?.length) { if (col.formItemProps?.rules?.length) {
col.formItemProps.rules = [] col.formItemProps.rules = []
} else if (typeof col.formItemProps === 'function') {
const formItemProps = col.formItemProps
col.formItemProps = (form, config) => {
const props = formItemProps(form, config)
return {
...props,
rules: [],
}
}
} }
return col return col
}) })
@ -190,6 +201,9 @@ export const getColumns = (columns: any[], key: string) => {
//生成ProTableColumns的宽度相关属性 //生成ProTableColumns的宽度相关属性
export const genProTableColumnWidthProps = (width: string | number) => { export const genProTableColumnWidthProps = (width: string | number) => {
if (!width) {
return {}
}
return { return {
width, width,
fieldProps: { fieldProps: {

Loading…
Cancel
Save