Browse Source

[Improvement][UI] Support to disable or enable the project preferences. (#14756)

3.2.1-prepare
calvin 1 year ago committed by GitHub
parent
commit
e2b97c026e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 16
      dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/controller/ProjectPreferenceController.java
  2. 1
      dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/enums/Status.java
  3. 2
      dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/service/ProjectPreferenceService.java
  4. 32
      dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/service/impl/ProjectPreferenceServiceImpl.java
  5. 2
      dolphinscheduler-api/src/main/resources/i18n/messages.properties
  6. 11
      dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/controller/ProjectPreferenceControllerTest.java
  7. 15
      dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/service/ProjectPreferenceServiceTest.java
  8. 3
      dolphinscheduler-dao/src/main/java/org/apache/dolphinscheduler/dao/entity/ProjectPreference.java
  9. 1
      dolphinscheduler-dao/src/main/resources/sql/dolphinscheduler_h2.sql
  10. 1
      dolphinscheduler-dao/src/main/resources/sql/dolphinscheduler_mysql.sql
  11. 1
      dolphinscheduler-dao/src/main/resources/sql/dolphinscheduler_postgresql.sql
  12. 4
      dolphinscheduler-ui/src/locales/en_US/project.ts
  13. 4
      dolphinscheduler-ui/src/locales/zh_CN/project.ts
  14. 14
      dolphinscheduler-ui/src/service/modules/projects-preference/index.ts
  15. 8
      dolphinscheduler-ui/src/service/modules/projects-preference/types.ts
  16. 68
      dolphinscheduler-ui/src/views/projects/preference/detail.tsx
  17. 24
      dolphinscheduler-ui/src/views/projects/preference/use-form.ts
  18. 2
      dolphinscheduler-ui/src/views/projects/task/components/node/detail-modal.tsx
  19. 2
      dolphinscheduler-ui/src/views/projects/workflow/definition/components/timing-modal.tsx

16
dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/controller/ProjectPreferenceController.java

@ -19,6 +19,7 @@ package org.apache.dolphinscheduler.api.controller;
import static org.apache.dolphinscheduler.api.enums.Status.QUERY_PROJECT_PREFERENCE_ERROR; import static org.apache.dolphinscheduler.api.enums.Status.QUERY_PROJECT_PREFERENCE_ERROR;
import static org.apache.dolphinscheduler.api.enums.Status.UPDATE_PROJECT_PREFERENCE_ERROR; import static org.apache.dolphinscheduler.api.enums.Status.UPDATE_PROJECT_PREFERENCE_ERROR;
import static org.apache.dolphinscheduler.api.enums.Status.UPDATE_PROJECT_PREFERENCE_STATE_ERROR;
import org.apache.dolphinscheduler.api.aspect.AccessLogAnnotation; import org.apache.dolphinscheduler.api.aspect.AccessLogAnnotation;
import org.apache.dolphinscheduler.api.exceptions.ApiException; import org.apache.dolphinscheduler.api.exceptions.ApiException;
@ -33,6 +34,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@ -79,4 +81,18 @@ public class ProjectPreferenceController extends BaseController {
return projectPreferenceService.queryProjectPreferenceByProjectCode(loginUser, projectCode); return projectPreferenceService.queryProjectPreferenceByProjectCode(loginUser, projectCode);
} }
@Operation(summary = "enableProjectPreference", description = "UPDATE_PROJECT_PREFERENCE_STATE_NOTES")
@Parameters({
@Parameter(name = "state", description = "PROJECT_PREFERENCES_STATE", schema = @Schema(implementation = String.class)),
})
@PostMapping
@ResponseStatus(HttpStatus.OK)
@ApiException(UPDATE_PROJECT_PREFERENCE_STATE_ERROR)
@AccessLogAnnotation(ignoreRequestArgs = "loginUser")
public Result enableProjectPreference(@Parameter(hidden = true) @RequestAttribute(value = Constants.SESSION_USER) User loginUser,
@Parameter(name = "projectCode", description = "PROJECT_CODE", required = true) @PathVariable long projectCode,
@RequestParam(value = "state", required = true) int state) {
return projectPreferenceService.enableProjectPreference(loginUser, projectCode, state);
}
} }

1
dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/enums/Status.java

@ -296,6 +296,7 @@ public enum Status {
UPDATE_PROJECT_PREFERENCE_ERROR(10301, "update project preference error", "更新项目偏好设置错误"), UPDATE_PROJECT_PREFERENCE_ERROR(10301, "update project preference error", "更新项目偏好设置错误"),
QUERY_PROJECT_PREFERENCE_ERROR(10302, "query project preference error", "查询项目偏好设置错误"), QUERY_PROJECT_PREFERENCE_ERROR(10302, "query project preference error", "查询项目偏好设置错误"),
UPDATE_PROJECT_PREFERENCE_STATE_ERROR(10303, "Failed to update the state of the project preference", "更新项目偏好设置错误"),
UDF_FUNCTION_NOT_EXIST(20001, "UDF function not found", "UDF函数不存在"), UDF_FUNCTION_NOT_EXIST(20001, "UDF function not found", "UDF函数不存在"),
UDF_FUNCTION_EXISTS(20002, "UDF function already exists", "UDF函数已存在"), UDF_FUNCTION_EXISTS(20002, "UDF function already exists", "UDF函数已存在"),

2
dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/service/ProjectPreferenceService.java

@ -25,4 +25,6 @@ public interface ProjectPreferenceService {
Result updateProjectPreference(User loginUser, long projectCode, String preferences); Result updateProjectPreference(User loginUser, long projectCode, String preferences);
Result queryProjectPreferenceByProjectCode(User loginUser, long projectCode); Result queryProjectPreferenceByProjectCode(User loginUser, long projectCode);
Result enableProjectPreference(User loginUser, long projectCode, int state);
} }

32
dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/service/impl/ProjectPreferenceServiceImpl.java

@ -77,6 +77,7 @@ public class ProjectPreferenceServiceImpl extends BaseServiceImpl
projectPreference.setPreferences(preferences); projectPreference.setPreferences(preferences);
projectPreference.setUserId(loginUser.getId()); projectPreference.setUserId(loginUser.getId());
projectPreference.setCode(CodeGenerateUtils.getInstance().genCode()); projectPreference.setCode(CodeGenerateUtils.getInstance().genCode());
projectPreference.setState(1);
projectPreference.setCreateTime(now); projectPreference.setCreateTime(now);
projectPreference.setUpdateTime(now); projectPreference.setUpdateTime(now);
if (projectPreferenceMapper.insert(projectPreference) > 0) { if (projectPreferenceMapper.insert(projectPreference) > 0) {
@ -123,4 +124,35 @@ public class ProjectPreferenceServiceImpl extends BaseServiceImpl
putMsg(result, Status.SUCCESS); putMsg(result, Status.SUCCESS);
return result; return result;
} }
@Override
public Result enableProjectPreference(User loginUser, long projectCode, int state) {
Result result = new Result();
// check if the user has the writing permission for project
Project project = projectMapper.queryByCode(projectCode);
boolean hasProjectAndWritePerm = projectService.hasProjectAndWritePerm(loginUser, project, result);
if (!hasProjectAndWritePerm) {
return result;
}
ProjectPreference projectPreference = projectPreferenceMapper
.selectOne(new QueryWrapper<ProjectPreference>().lambda().eq(ProjectPreference::getProjectCode,
projectCode));
putMsg(result, Status.SUCCESS);
if (Objects.nonNull(projectPreference) && projectPreference.getState() != state) {
projectPreference.setState(state);
projectPreference.setUpdateTime(new Date());
if (projectPreferenceMapper.updateById(projectPreference) > 0) {
log.info("The state of the project preference is updated and id is :{}", projectPreference.getId());
} else {
log.error("Failed to update the state of the project preference, projectCode:{}.",
projectPreference.getProjectCode());
putMsg(result, Status.UPDATE_PROJECT_PREFERENCE_STATE_ERROR);
}
}
return result;
}
} }

2
dolphinscheduler-api/src/main/resources/i18n/messages.properties

@ -451,5 +451,7 @@ QUERY_PROJECT_PARAMETER_NOTES=query project parameter
PROJECT_PREFERENCE_TAG=project preference related operation PROJECT_PREFERENCE_TAG=project preference related operation
UPDATE_PROJECT_PREFERENCE_NOTES=update project preference UPDATE_PROJECT_PREFERENCE_NOTES=update project preference
UPDATE_PROJECT_PREFERENCE_STATE_NOTES=update the state of the project preference
PROJECT_PREFERENCES_STATE= the state of the project preference
PROJECT_PREFERENCES=project preferences PROJECT_PREFERENCES=project preferences
QUERY_PROJECT_PREFERENCE_NOTES=query project preference QUERY_PROJECT_PREFERENCE_NOTES=query project preference

11
dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/controller/ProjectPreferenceControllerTest.java

@ -64,6 +64,17 @@ public class ProjectPreferenceControllerTest {
Assertions.assertEquals(Status.SUCCESS.getCode(), result.getCode()); Assertions.assertEquals(Status.SUCCESS.getCode(), result.getCode());
} }
@Test
public void testEnableProjectPreference() {
User loginUser = getGeneralUser();
Mockito.when(
projectPreferenceService.enableProjectPreference(Mockito.any(), Mockito.anyLong(), Mockito.anyInt()))
.thenReturn(getSuccessResult());
Result result = projectPreferenceController.enableProjectPreference(loginUser, 1, 1);
Assertions.assertEquals(Status.SUCCESS.getCode(), result.getCode());
}
private User getGeneralUser() { private User getGeneralUser() {
User loginUser = new User(); User loginUser = new User();
loginUser.setUserType(UserType.GENERAL_USER); loginUser.setUserType(UserType.GENERAL_USER);

15
dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/service/ProjectPreferenceServiceTest.java

@ -90,6 +90,20 @@ public class ProjectPreferenceServiceTest {
Assertions.assertEquals(Status.SUCCESS.getCode(), result.getCode()); Assertions.assertEquals(Status.SUCCESS.getCode(), result.getCode());
} }
@Test
public void testEnableProjectPreference() {
User loginUser = getGeneralUser();
Mockito.when(projectMapper.queryByCode(projectCode)).thenReturn(getProject(projectCode));
Mockito.when(projectService.hasProjectAndWritePerm(Mockito.any(), Mockito.any(), Mockito.any(Result.class)))
.thenReturn(true);
Mockito.when(projectPreferenceMapper.selectOne(Mockito.any())).thenReturn(getProjectPreference());
Result result = projectPreferenceService.enableProjectPreference(loginUser, projectCode, 1);
Assertions.assertEquals(Status.SUCCESS.getCode(), result.getCode());
}
private User getGeneralUser() { private User getGeneralUser() {
User loginUser = new User(); User loginUser = new User();
loginUser.setUserType(UserType.GENERAL_USER); loginUser.setUserType(UserType.GENERAL_USER);
@ -113,6 +127,7 @@ public class ProjectPreferenceServiceTest {
projectPreference.setCode(1); projectPreference.setCode(1);
projectPreference.setProjectCode(projectCode); projectPreference.setProjectCode(projectCode);
projectPreference.setPreferences("value"); projectPreference.setPreferences("value");
projectPreference.setState(1);
return projectPreference; return projectPreference;
} }
} }

3
dolphinscheduler-dao/src/main/java/org/apache/dolphinscheduler/dao/entity/ProjectPreference.java

@ -50,6 +50,9 @@ public class ProjectPreference {
@TableField("user_id") @TableField("user_id")
private Integer userId; private Integer userId;
@TableField("state")
private int state;
private Date createTime; private Date createTime;
private Date updateTime; private Date updateTime;

1
dolphinscheduler-dao/src/main/resources/sql/dolphinscheduler_h2.sql

@ -692,6 +692,7 @@ CREATE TABLE t_ds_project_preference
project_code bigint(20) NOT NULL, project_code bigint(20) NOT NULL,
preferences varchar(512) NOT NULL, preferences varchar(512) NOT NULL,
user_id int(11) DEFAULT NULL, user_id int(11) DEFAULT NULL,
state int(11) DEFAULT '1',
create_time datetime NOT NULL, create_time datetime NOT NULL,
update_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),

1
dolphinscheduler-dao/src/main/resources/sql/dolphinscheduler_mysql.sql

@ -694,6 +694,7 @@ CREATE TABLE `t_ds_project_preference` (
`project_code` bigint(20) NOT NULL COMMENT 'project code', `project_code` bigint(20) NOT NULL COMMENT 'project code',
`preferences` varchar(512) NOT NULL COMMENT 'project preferences', `preferences` varchar(512) NOT NULL COMMENT 'project preferences',
`user_id` int(11) DEFAULT NULL COMMENT 'creator id', `user_id` int(11) DEFAULT NULL COMMENT 'creator id',
`state` int(11) DEFAULT '1' comment '1 means enabled, 0 means disabled',
`create_time` datetime NOT NULL COMMENT 'create time', `create_time` datetime NOT NULL COMMENT 'create time',
`update_time` datetime DEFAULT NULL COMMENT 'update time', `update_time` datetime DEFAULT NULL COMMENT 'update time',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),

1
dolphinscheduler-dao/src/main/resources/sql/dolphinscheduler_postgresql.sql

@ -618,6 +618,7 @@ CREATE TABLE t_ds_project_preference
project_code bigint NOT NULL, project_code bigint NOT NULL,
preferences varchar(512) NOT NULL, preferences varchar(512) NOT NULL,
user_id int DEFAULT NULL , user_id int DEFAULT NULL ,
state int default 1,
create_time timestamp DEFAULT CURRENT_TIMESTAMP , create_time timestamp DEFAULT CURRENT_TIMESTAMP ,
update_time timestamp DEFAULT CURRENT_TIMESTAMP , update_time timestamp DEFAULT CURRENT_TIMESTAMP ,
PRIMARY KEY (id) PRIMARY KEY (id)

4
dolphinscheduler-ui/src/locales/en_US/project.ts

@ -909,6 +909,8 @@ export default {
preference_manage: 'Project Preference Management', preference_manage: 'Project Preference Management',
instruction_tips: 'The settings below will affect all workflows and tasks under this project.When creating the workflow or task, these preferences will be the default value of their components.', instruction_tips: 'The settings below will affect all workflows and tasks under this project.When creating the workflow or task, these preferences will be the default value of their components.',
success: 'Success', success: 'Success',
submit: 'Submit' submit: 'Submit',
enabled: 'Enabled',
disabled: 'Disabled'
}, },
} }

4
dolphinscheduler-ui/src/locales/zh_CN/project.ts

@ -883,6 +883,8 @@ export default {
preference_manage: '项目偏好管理', preference_manage: '项目偏好管理',
instruction_tips: '下面这些项目偏好配置将影响这个项目下的所有工作流和任务。当创建工作流和任务时,这些配置将影响组件中的默认选项', instruction_tips: '下面这些项目偏好配置将影响这个项目下的所有工作流和任务。当创建工作流和任务时,这些配置将影响组件中的默认选项',
success: '成功', success: '成功',
submit: '提交' submit: '提交',
enabled: '启用',
disabled: '未启用'
}, },
} }

14
dolphinscheduler-ui/src/service/modules/projects-preference/index.ts

@ -18,7 +18,8 @@
import { axios } from '@/service/service' import { axios } from '@/service/service'
import { import {
UpdateProjectPreferenceReq UpdateProjectPreferenceReq,
UpdateProjectPreferenceStateReq
} from './types' } from './types'
export function queryProjectPreferenceByProjectCode( export function queryProjectPreferenceByProjectCode(
@ -39,4 +40,15 @@ export function updateProjectPreference(
method: 'put', method: 'put',
data data
}) })
}
export function updateProjectPreferenceState(
data: UpdateProjectPreferenceStateReq,
projectCode: number,
): any {
return axios({
url: `/projects/${projectCode}/project-preference`,
method: 'post',
data
})
} }

8
dolphinscheduler-ui/src/service/modules/projects-preference/types.ts

@ -24,11 +24,16 @@ interface UpdateProjectPreferenceReq extends ProjectPreferenceReq {
code: number code: number
} }
interface UpdateProjectPreferenceStateReq {
state: number
}
interface ProjectPreferenceRes { interface ProjectPreferenceRes {
id: number id: number
code: number code: number
projectCode: number projectCode: number
preferences: string preferences: string
state: number
createTime: string createTime: string
updateTime: string updateTime: string
} }
@ -36,5 +41,6 @@ interface ProjectPreferenceRes {
export { export {
ProjectPreferenceRes, ProjectPreferenceRes,
ProjectPreferenceReq, ProjectPreferenceReq,
UpdateProjectPreferenceReq UpdateProjectPreferenceReq,
UpdateProjectPreferenceStateReq
} }

68
dolphinscheduler-ui/src/views/projects/preference/detail.tsx

@ -18,7 +18,7 @@
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import Form from '@/components/form' import Form from '@/components/form'
import { useForm } from './use-form' import { useForm } from './use-form'
import { NButton, NDivider, NSpace } from 'naive-ui' import { NButton, NDivider, NSpace, NSwitch } from 'naive-ui'
const PreferenceForm = defineComponent({ const PreferenceForm = defineComponent({
name: 'PreferenceForm', name: 'PreferenceForm',
@ -28,36 +28,56 @@ const PreferenceForm = defineComponent({
elementsRef, elementsRef,
rulesRef, rulesRef,
model, model,
stateRef,
formProps, formProps,
t, t,
handleUpdate handleUpdate,
handleUpdateState
} = useForm() } = useForm()
return () => ( return () => (
<div> <div>
<div style={{ margin: '30px' }}> <div style={{ marginLeft: '30px' }}>
{t('project.preference.instruction_tips')} <NSwitch
size={'large'}
round={false}
v-model:value={stateRef.value}
checkedValue={1}
uncheckedValue={0}
onUpdateValue={handleUpdateState}
>
{{
checked: () => t('project.preference.enabled'),
unchecked: () => t('project.preference.disabled')
}}
</NSwitch>
</div>
<div>
<div style={{ margin: '30px' }}>
{t('project.preference.instruction_tips')}
</div>
<NDivider />
<Form
ref={formRef}
meta={{
model,
disabled: stateRef.value === 1 ? false : true,
rules: rulesRef.value,
elements: elementsRef.value,
...formProps.value
}}
layout={{
xGap: 10
}}
style={{ marginLeft: '150px' }}
/>
<NDivider />
<NSpace justify='center'>
<NButton v-show={stateRef.value} type='info' onClick={handleUpdate}>
{t('project.preference.submit')}
</NButton>
</NSpace>
</div> </div>
<NDivider />
<Form
ref={formRef}
meta={{
model,
rules: rulesRef.value,
elements: elementsRef.value,
...formProps.value
}}
layout={{
xGap: 10
}}
style={{ marginLeft: '150px' }}
/>
<NDivider />
<NSpace justify='end'>
<NButton type='info' onClick={handleUpdate}>
{t('project.preference.submit')}
</NButton>
</NSpace>
</div> </div>
) )
} }

24
dolphinscheduler-ui/src/views/projects/preference/use-form.ts

@ -25,10 +25,14 @@ import * as Fields from '@/views/projects/task/components/node/fields'
import { Router, useRouter } from 'vue-router' import { Router, useRouter } from 'vue-router'
import { import {
queryProjectPreferenceByProjectCode, queryProjectPreferenceByProjectCode,
updateProjectPreference updateProjectPreference,
updateProjectPreferenceState
} from '@/service/modules/projects-preference' } from '@/service/modules/projects-preference'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { UpdateProjectPreferenceReq } from '@/service/modules/projects-preference/types' import {
UpdateProjectPreferenceReq,
UpdateProjectPreferenceStateReq
} from '@/service/modules/projects-preference/types'
import { useWarningType } from '@/views/projects/preference/components/use-warning-type' import { useWarningType } from '@/views/projects/preference/components/use-warning-type'
import { useTenant } from '@/views/projects/preference/components/use-tenant' import { useTenant } from '@/views/projects/preference/components/use-tenant'
import { useAlertGroup } from '@/views/projects/preference/components/use-alert-group' import { useAlertGroup } from '@/views/projects/preference/components/use-alert-group'
@ -44,6 +48,7 @@ export function useForm() {
const elementsRef = ref([]) as Ref<IFormItem[]> const elementsRef = ref([]) as Ref<IFormItem[]>
const rulesRef = ref({}) const rulesRef = ref({})
const formProps = ref({}) const formProps = ref({})
const stateRef = ref(0)
formProps.value = { formProps.value = {
labelPlacement: 'left', labelPlacement: 'left',
@ -75,6 +80,7 @@ export function useForm() {
const result = await queryProjectPreferenceByProjectCode(projectCode) const result = await queryProjectPreferenceByProjectCode(projectCode)
if (result?.preferences) { if (result?.preferences) {
setValues(JSON.parse(result.preferences)) setValues(JSON.parse(result.preferences))
stateRef.value = result.state
} }
} }
} }
@ -92,6 +98,16 @@ export function useForm() {
}) })
} }
const handleUpdateState = (value: number) => {
const requestData = {
state: value
} as UpdateProjectPreferenceStateReq
updateProjectPreferenceState(requestData, projectCode).then(() => {
window.$message.success(t('project.preference.success'))
})
}
const preferencesItems: IJsonItem[] = [ const preferencesItems: IJsonItem[] = [
Fields.useTaskPriority(), Fields.useTaskPriority(),
useTenant(), useTenant(),
@ -129,8 +145,10 @@ export function useForm() {
elementsRef, elementsRef,
rulesRef, rulesRef,
model: data.model, model: data.model,
stateRef,
formProps, formProps,
t, t,
handleUpdate handleUpdate,
handleUpdateState
} }
} }

2
dolphinscheduler-ui/src/views/projects/task/components/node/detail-modal.tsx

@ -122,7 +122,7 @@ const NodeDetailModal = defineComponent({
const initProjectPreferences = (projectCode: number) => { const initProjectPreferences = (projectCode: number) => {
queryProjectPreferenceByProjectCode(projectCode).then((result: any) => { queryProjectPreferenceByProjectCode(projectCode).then((result: any) => {
if (result?.preferences) { if (result?.preferences && result.state === 1) {
projectPreferences.value = JSON.parse(result.preferences) projectPreferences.value = JSON.parse(result.preferences)
} }
}) })

2
dolphinscheduler-ui/src/views/projects/workflow/definition/components/timing-modal.tsx

@ -102,7 +102,7 @@ export default defineComponent({
const initProjectPreferences = (projectCode: number) => { const initProjectPreferences = (projectCode: number) => {
queryProjectPreferenceByProjectCode(projectCode).then((result: any) => { queryProjectPreferenceByProjectCode(projectCode).then((result: any) => {
if (result?.preferences) { if (result?.preferences && result.state === 1) {
projectPreferences.value = JSON.parse(result.preferences) projectPreferences.value = JSON.parse(result.preferences)
} }
}) })

Loading…
Cancel
Save