Skip to content

Commit a501c3c

Browse files
Implement Snippet Lock Feature to Prevent Accidental Deletion and Modification
1 parent 2faeb05 commit a501c3c

File tree

16 files changed

+223
-48
lines changed

16 files changed

+223
-48
lines changed

src/css/edit/_sidebar.scss

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,19 @@
2323
.delete-button {
2424
color: #cc1818;
2525

26-
&:hover {
26+
&:hover:not(:disabled) {
2727
color: #9e1313;
2828
}
2929

30-
&:focus {
30+
&:focus:not(:disabled) {
3131
color: #710d0d;
3232
border-color: #710d0d;
3333
}
34+
35+
&:disabled {
36+
color: #a7aaad;
37+
cursor: not-allowed;
38+
}
3439
}
3540

3641
.help-tooltip {
@@ -61,6 +66,19 @@
6166
> :last-child {
6267
margin-inline-start: auto;
6368
}
69+
70+
&.lock-control-container {
71+
border-block-start: 1px solid #eee;
72+
padding-block-start: 1em;
73+
margin-block-start: 0.5em;
74+
75+
.description {
76+
flex-basis: 100%;
77+
margin-block-start: 4px;
78+
color: #646970;
79+
font-style: italic;
80+
}
81+
}
6482
}
6583

6684
.block-form-field {
@@ -108,11 +126,15 @@ p.submit {
108126
padding-block-start: 0;
109127
}
110128

111-
.activation-switch-container label {
112-
display: flex;
113-
flex-flow: row;
114-
gap: 5px;
115-
justify-content: center;
129+
.activation-switch-container,
130+
.lock-control-container {
131+
label {
132+
display: flex;
133+
flex-flow: row;
134+
gap: 5px;
135+
justify-content: center;
136+
align-items: center;
137+
}
116138
}
117139

118140
.shortcode-tag-wrapper {
@@ -152,4 +174,4 @@ p.submit {
152174
.components-spinner {
153175
block-size: 12px;
154176
}
155-
}
177+
}

src/css/manage.scss

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
@use 'common/direction';
1111
@use 'common/select';
1212
@use 'manage/cloud';
13-
13+
1414
.column-name,
1515
.column-type {
1616
.dashicons {
@@ -23,6 +23,17 @@
2323
.dashicons-clock {
2424
vertical-align: middle;
2525
}
26+
27+
.dashicons-lock {
28+
color: #646970;
29+
margin-inline-start: 4px;
30+
opacity: 0.7;
31+
cursor: help;
32+
33+
&:hover {
34+
opacity: 1;
35+
}
36+
}
2637
}
2738

2839
.active-snippet .column-name > .snippet-name {
@@ -91,6 +102,14 @@
91102
color: #ddd;
92103
position: relative;
93104
inset-inline-start: 0;
105+
106+
.delete {
107+
&.disabled {
108+
color: #a7aaad;
109+
cursor: not-allowed;
110+
pointer-events: none;
111+
}
112+
}
94113
}
95114

96115
.column-activate {
@@ -128,7 +147,7 @@
128147
}
129148

130149
&, #all-snippets-table, #search-snippets-table {
131-
a.delete:hover {
150+
a.delete:not(.disabled):hover {
132151
border-block-end: 1px solid #f00;
133152
color: #f00;
134153
}
@@ -212,4 +231,4 @@ td.column-description {
212231
display: none;
213232
}
214233
}
215-
}
234+
}

src/js/components/EditorSidebar/EditorSidebar.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { MultisiteSharingSettings } from './controls/MultisiteSharingSettings'
1212
import { ExportButtons } from './actions/ExportButtons'
1313
import { SubmitButtons } from './actions/SubmitButtons'
1414
import { ActivationSwitch } from './controls/ActivationSwitch'
15+
import { LockControl } from './controls/LockControl'
1516
import { DeleteButton } from './actions/DeleteButton'
1617
import { PriorityInput } from './controls/PriorityInput'
1718
import { RTLControl } from './controls/RTLControl'
@@ -29,6 +30,8 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({ setIsUpgradeDialog
2930
<div className="box">
3031
{snippet.id && !isCondition(snippet) ? <ActivationSwitch /> : null}
3132

33+
{snippet.id ? <LockControl /> : null}
34+
3235
{isNetworkAdmin() ? <MultisiteSharingSettings /> : null}
3336

3437
{isRTL() ? <RTLControl /> : null}
@@ -53,4 +56,4 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({ setIsUpgradeDialog
5356
<Notices />
5457
</div>
5558
)
56-
}
59+
}

src/js/components/EditorSidebar/actions/DeleteButton.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const DeleteButton: React.FC = () => {
1616
<Button
1717
id="delete-snippet"
1818
className="delete-button"
19-
disabled={isWorking}
19+
disabled={isWorking || snippet.locked}
2020
onClick={() => {
2121
setIsDialogOpen(true)
2222
}}
@@ -49,4 +49,4 @@ export const DeleteButton: React.FC = () => {
4949
</ConfirmDialog>
5050
</>
5151
)
52-
}
52+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from 'react'
2+
import { __ } from '@wordpress/i18n'
3+
import { useSnippetForm } from '../../../hooks/useSnippetForm'
4+
import { SubmitSnippetAction, useSubmitSnippet } from '../../../hooks/useSubmitSnippet'
5+
import { handleUnknownError } from '../../../utils/errors'
6+
7+
export const LockControl: React.FC = () => {
8+
const { snippet, setSnippet, isWorking } = useSnippetForm()
9+
const { submitSnippet } = useSubmitSnippet()
10+
11+
const handleToggle = () => {
12+
const newLockedStatus = !snippet.locked
13+
14+
// Create the updated snippet object immediately
15+
const updatedSnippet = {
16+
...snippet,
17+
locked: newLockedStatus
18+
}
19+
20+
// Update local state for immediate UI response
21+
setSnippet(updatedSnippet)
22+
23+
// Submit to the server using the override to prevent stale state issues
24+
submitSnippet(SubmitSnippetAction.SAVE, updatedSnippet)
25+
.then(() => undefined)
26+
.catch(handleUnknownError)
27+
}
28+
29+
return (
30+
<div className="inline-form-field lock-control-container">
31+
<h4>{__('Lock Snippet', 'code-snippets')}</h4>
32+
33+
<label>
34+
{snippet.locked
35+
? __('Locked', 'code-snippets')
36+
: __('Unlocked', 'code-snippets')}
37+
38+
<input
39+
id="snippet-lock"
40+
type="checkbox"
41+
checked={snippet.locked}
42+
disabled={isWorking}
43+
className="switch"
44+
onChange={handleToggle}
45+
/>
46+
</label>
47+
<p className="description">
48+
{__('Prevent accidental changes or deletion.', 'code-snippets')}
49+
</p>
50+
</div>
51+
)
52+
}

src/js/hooks/useSnippetForm.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ export const WithSnippetFormContext: React.FC<WithSnippetFormContextProps> = ({
3434
const [currentNotice, setCurrentNotice] = useState<ScreenNotice>()
3535
const [codeEditorInstance, setCodeEditorInstance] = useState<CodeEditorInstance>()
3636

37-
const isReadOnly = useMemo(() => !isLicensed() && isProSnippet({ scope: snippet.scope }), [snippet.scope])
37+
const isReadOnly = useMemo(
38+
() => snippet.locked || (!isLicensed() && isProSnippet({ scope: snippet.scope })),
39+
[snippet.locked, snippet.scope]
40+
)
3841

3942
const handleRequestError = useCallback((error: unknown, message?: string) => {
4043
console.error('Request failed', error)
@@ -66,4 +69,4 @@ export const WithSnippetFormContext: React.FC<WithSnippetFormContextProps> = ({
6669
}
6770

6871
return <SnippetFormContext.Provider value={value}>{children}</SnippetFormContext.Provider>
69-
}
72+
}

src/js/hooks/useSubmitSnippet.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -76,50 +76,58 @@ const SUBMIT_ACTION_DELTA: Record<SubmitSnippetAction, Partial<Snippet>> = {
7676
}
7777

7878
export interface UseSubmitSnippet {
79-
submitSnippet: (action?: SubmitSnippetAction) => Promise<Snippet | undefined>
79+
submitSnippet: (action?: SubmitSnippetAction, snippetOverride?: Snippet) => Promise<Snippet | undefined>
8080
}
8181

8282
export const useSubmitSnippet = (): UseSubmitSnippet => {
8383
const { snippetsAPI } = useRestAPI()
8484
const { setIsWorking, setCurrentNotice, snippet, setSnippet } = useSnippetForm()
8585

86-
const submitSnippet = useCallback(async (action: SubmitSnippetAction = SubmitSnippetAction.SAVE) => {
86+
const submitSnippet = useCallback(async (
87+
action: SubmitSnippetAction = SubmitSnippetAction.SAVE,
88+
snippetOverride?: Snippet
89+
) => {
8790
setCurrentNotice(undefined)
91+
setIsWorking(true)
92+
93+
// Use the override if provided (prevents stale state issues), otherwise use current context state
94+
const activeSnippet = snippetOverride ?? snippet
8895

8996
const result = await (async (): Promise<Snippet | string | undefined> => {
9097
try {
91-
const request: Snippet = { ...snippet, ...SUBMIT_ACTION_DELTA[action] }
98+
const request: Snippet = { ...activeSnippet, ...SUBMIT_ACTION_DELTA[action] }
9299
const response = await (0 === request.id ? snippetsAPI.create(request) : snippetsAPI.update(request))
93-
setIsWorking(false)
94100
return response.id ? response : undefined
95101
} catch (error) {
96-
setIsWorking(false)
97102
return isAxiosError(error) ? error.message : undefined
103+
} finally {
104+
setIsWorking(false)
98105
}
99106
})()
100107

101-
const messages = isCondition(snippet) ? conditionMessages : snippetMessages
108+
const messages = isCondition(activeSnippet) ? conditionMessages : snippetMessages
102109

103110
if (undefined === result || 'string' === typeof result) {
104111
const message = [
105-
snippet.id ? messages.failedUpdate : messages.failedCreate,
112+
activeSnippet.id ? messages.failedUpdate : messages.failedCreate,
106113
result ?? __('The server did not send a valid response.', 'code-snippets')
107114
]
108115

109116
setCurrentNotice(['error', message.filter(Boolean).join(' ')])
110117
return undefined
111118
} else {
112-
setSnippet(createSnippetObject(result))
113-
setCurrentNotice(['updated', getSuccessNotice(snippet, result, action)])
119+
const updatedSnippet = createSnippetObject(result)
120+
setSnippet(updatedSnippet)
121+
setCurrentNotice(['updated', getSuccessNotice(activeSnippet, updatedSnippet, action)])
114122

115-
if (snippet.id && result.id) {
123+
if (activeSnippet.id && updatedSnippet.id) {
116124
window.document.title = window.document.title.replace(snippetMessages.addNew, messages.edit)
117-
window.history.replaceState({}, '', addQueryArgs(window.CODE_SNIPPETS?.urls.edit, { id: result.id }))
125+
window.history.replaceState({}, '', addQueryArgs(window.CODE_SNIPPETS?.urls.edit, { id: updatedSnippet.id }))
118126
}
119127

120-
return result
128+
return updatedSnippet
121129
}
122130
}, [snippetsAPI, setIsWorking, setCurrentNotice, snippet, setSnippet])
123131

124132
return { submitSnippet }
125-
}
133+
}

src/js/types/Snippet.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface Snippet {
77
readonly scope: SnippetScope
88
readonly priority: number
99
readonly active: boolean
10+
readonly locked: boolean
1011
readonly network: boolean
1112
readonly shared_network?: boolean | null
1213
readonly modified?: string
@@ -26,4 +27,4 @@ export const SNIPPET_TYPE_SCOPES = <const> {
2627
css: ['admin-css', 'site-css'],
2728
js: ['site-head-js', 'site-footer-js'],
2829
cond: ['condition']
29-
}
30+
}

src/js/types/schema/SnippetSchema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface WritableSnippetSchema {
88
scope?: SnippetScope
99
condition_id?: number
1010
active?: boolean
11+
locked?: boolean
1112
priority?: number
1213
network?: boolean | null
1314
shared_network?: boolean | null
@@ -17,4 +18,4 @@ export interface SnippetSchema extends Readonly<Required<WritableSnippetSchema>>
1718
readonly id: number
1819
readonly modified: string
1920
readonly code_error?: readonly [string, number] | null
20-
}
21+
}

src/js/utils/snippets/api.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const mapToSchema = ({
3434
scope,
3535
priority,
3636
active,
37+
locked,
3738
network,
3839
shared_network,
3940
conditionId
@@ -45,6 +46,7 @@ const mapToSchema = ({
4546
scope,
4647
priority,
4748
active,
49+
locked,
4850
network,
4951
shared_network,
5052
condition_id: conditionId
@@ -89,4 +91,4 @@ export const buildSnippetsAPI = ({ get, post, del, put }: RestAPI): SnippetsAPI
8991

9092
detach: snippet =>
9193
put(buildURL(snippet, 'detach'))
92-
})
94+
})

0 commit comments

Comments
 (0)