Skip to content
Open
38 changes: 30 additions & 8 deletions src/css/edit/_sidebar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,19 @@
.delete-button {
color: #cc1818;

&:hover {
&:hover:not(:disabled) {
color: #9e1313;
}

&:focus {
&:focus:not(:disabled) {
color: #710d0d;
border-color: #710d0d;
}

&:disabled {
color: #a7aaad;
cursor: not-allowed;
}
}

.help-tooltip {
Expand Down Expand Up @@ -61,6 +66,19 @@
> :last-child {
margin-inline-start: auto;
}

&.lock-control-container {
border-block-start: 1px solid #eee;
padding-block-start: 1em;
margin-block-start: 0.5em;

.description {
flex-basis: 100%;
margin-block-start: 4px;
color: #646970;
font-style: italic;
}
}
}

.block-form-field {
Expand Down Expand Up @@ -108,11 +126,15 @@ p.submit {
padding-block-start: 0;
}

.activation-switch-container label {
display: flex;
flex-flow: row;
gap: 5px;
justify-content: center;
.activation-switch-container,
.lock-control-container {
label {
display: flex;
flex-flow: row;
gap: 5px;
justify-content: center;
align-items: center;
}
}

.shortcode-tag-wrapper {
Expand Down Expand Up @@ -152,4 +174,4 @@ p.submit {
.components-spinner {
block-size: 12px;
}
}
}
25 changes: 22 additions & 3 deletions src/css/manage.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
@use 'common/direction';
@use 'common/select';
@use 'manage/cloud';

.column-name,
.column-type {
.dashicons {
Expand All @@ -23,6 +23,17 @@
.dashicons-clock {
vertical-align: middle;
}

.dashicons-lock {
color: #646970;
margin-inline-start: 4px;
opacity: 0.7;
cursor: help;

&:hover {
opacity: 1;
}
}
}

.active-snippet .column-name > .snippet-name {
Expand Down Expand Up @@ -91,6 +102,14 @@
color: #ddd;
position: relative;
inset-inline-start: 0;

.delete {
&.disabled {
color: #a7aaad;
cursor: not-allowed;
pointer-events: none;
}
}
}

.column-activate {
Expand Down Expand Up @@ -128,7 +147,7 @@
}

&, #all-snippets-table, #search-snippets-table {
a.delete:hover {
a.delete:not(.disabled):hover {
border-block-end: 1px solid #f00;
color: #f00;
}
Expand Down Expand Up @@ -212,4 +231,4 @@ td.column-description {
display: none;
}
}
}
}
5 changes: 4 additions & 1 deletion src/js/components/EditorSidebar/EditorSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { MultisiteSharingSettings } from './controls/MultisiteSharingSettings'
import { ExportButtons } from './actions/ExportButtons'
import { SubmitButtons } from './actions/SubmitButtons'
import { ActivationSwitch } from './controls/ActivationSwitch'
import { LockControl } from './controls/LockControl'
import { DeleteButton } from './actions/DeleteButton'
import { PriorityInput } from './controls/PriorityInput'
import { RTLControl } from './controls/RTLControl'
Expand All @@ -29,6 +30,8 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({ setIsUpgradeDialog
<div className="box">
{snippet.id && !isCondition(snippet) ? <ActivationSwitch /> : null}

{snippet.id ? <LockControl /> : null}

{isNetworkAdmin() ? <MultisiteSharingSettings /> : null}

{isRTL() ? <RTLControl /> : null}
Expand All @@ -53,4 +56,4 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({ setIsUpgradeDialog
<Notices />
</div>
)
}
}
4 changes: 2 additions & 2 deletions src/js/components/EditorSidebar/actions/DeleteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const DeleteButton: React.FC = () => {
<Button
id="delete-snippet"
className="delete-button"
disabled={isWorking}
disabled={isWorking || snippet.locked}
onClick={() => {
setIsDialogOpen(true)
}}
Expand Down Expand Up @@ -49,4 +49,4 @@ export const DeleteButton: React.FC = () => {
</ConfirmDialog>
</>
)
}
}
52 changes: 52 additions & 0 deletions src/js/components/EditorSidebar/controls/LockControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react'
import { __ } from '@wordpress/i18n'
import { useSnippetForm } from '../../../hooks/useSnippetForm'
import { SubmitSnippetAction, useSubmitSnippet } from '../../../hooks/useSubmitSnippet'
import { handleUnknownError } from '../../../utils/errors'

export const LockControl: React.FC = () => {
const { snippet, setSnippet, isWorking } = useSnippetForm()
const { submitSnippet } = useSubmitSnippet()

const handleToggle = () => {
const newLockedStatus = !snippet.locked

// Create the updated snippet object immediately
const updatedSnippet = {
...snippet,
locked: newLockedStatus
}

// Update local state for immediate UI response
setSnippet(updatedSnippet)

// Submit to the server using the override to prevent stale state issues
submitSnippet(SubmitSnippetAction.SAVE, updatedSnippet)
.then(() => undefined)
.catch(handleUnknownError)
}

return (
<div className="inline-form-field lock-control-container">
<h4>{__('Lock Snippet', 'code-snippets')}</h4>

<label>
{snippet.locked
? __('Locked', 'code-snippets')
: __('Unlocked', 'code-snippets')}

<input
id="snippet-lock"
type="checkbox"
checked={snippet.locked}
disabled={isWorking}
className="switch"
onChange={handleToggle}
/>
</label>
<p className="description">
{__('Prevent accidental changes or deletion.', 'code-snippets')}
</p>
</div>
)
}
7 changes: 5 additions & 2 deletions src/js/hooks/useSnippetForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ export const WithSnippetFormContext: React.FC<WithSnippetFormContextProps> = ({
const [currentNotice, setCurrentNotice] = useState<ScreenNotice>()
const [codeEditorInstance, setCodeEditorInstance] = useState<CodeEditorInstance>()

const isReadOnly = useMemo(() => !isLicensed() && isProSnippet({ scope: snippet.scope }), [snippet.scope])
const isReadOnly = useMemo(
() => snippet.locked || (!isLicensed() && isProSnippet({ scope: snippet.scope })),
[snippet.locked, snippet.scope]
)

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

return <SnippetFormContext.Provider value={value}>{children}</SnippetFormContext.Provider>
}
}
34 changes: 21 additions & 13 deletions src/js/hooks/useSubmitSnippet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,50 +76,58 @@ const SUBMIT_ACTION_DELTA: Record<SubmitSnippetAction, Partial<Snippet>> = {
}

export interface UseSubmitSnippet {
submitSnippet: (action?: SubmitSnippetAction) => Promise<Snippet | undefined>
submitSnippet: (action?: SubmitSnippetAction, snippetOverride?: Snippet) => Promise<Snippet | undefined>
}

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

const submitSnippet = useCallback(async (action: SubmitSnippetAction = SubmitSnippetAction.SAVE) => {
const submitSnippet = useCallback(async (
action: SubmitSnippetAction = SubmitSnippetAction.SAVE,
snippetOverride?: Snippet
) => {
setCurrentNotice(undefined)
setIsWorking(true)

// Use the override if provided (prevents stale state issues), otherwise use current context state
const activeSnippet = snippetOverride ?? snippet

const result = await (async (): Promise<Snippet | string | undefined> => {
try {
const request: Snippet = { ...snippet, ...SUBMIT_ACTION_DELTA[action] }
const request: Snippet = { ...activeSnippet, ...SUBMIT_ACTION_DELTA[action] }
const response = await (0 === request.id ? snippetsAPI.create(request) : snippetsAPI.update(request))
setIsWorking(false)
return response.id ? response : undefined
} catch (error) {
setIsWorking(false)
return isAxiosError(error) ? error.message : undefined
} finally {
setIsWorking(false)
}
})()

const messages = isCondition(snippet) ? conditionMessages : snippetMessages
const messages = isCondition(activeSnippet) ? conditionMessages : snippetMessages

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

setCurrentNotice(['error', message.filter(Boolean).join(' ')])
return undefined
} else {
setSnippet(createSnippetObject(result))
setCurrentNotice(['updated', getSuccessNotice(snippet, result, action)])
const updatedSnippet = createSnippetObject(result)
setSnippet(updatedSnippet)
setCurrentNotice(['updated', getSuccessNotice(activeSnippet, updatedSnippet, action)])

if (snippet.id && result.id) {
if (activeSnippet.id && updatedSnippet.id) {
window.document.title = window.document.title.replace(snippetMessages.addNew, messages.edit)
window.history.replaceState({}, '', addQueryArgs(window.CODE_SNIPPETS?.urls.edit, { id: result.id }))
window.history.replaceState({}, '', addQueryArgs(window.CODE_SNIPPETS?.urls.edit, { id: updatedSnippet.id }))
}

return result
return updatedSnippet
}
}, [snippetsAPI, setIsWorking, setCurrentNotice, snippet, setSnippet])

return { submitSnippet }
}
}
3 changes: 2 additions & 1 deletion src/js/types/Snippet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface Snippet {
readonly scope: SnippetScope
readonly priority: number
readonly active: boolean
readonly locked: boolean
readonly network: boolean
readonly shared_network?: boolean | null
readonly modified?: string
Expand All @@ -26,4 +27,4 @@ export const SNIPPET_TYPE_SCOPES = <const> {
css: ['admin-css', 'site-css'],
js: ['site-head-js', 'site-footer-js'],
cond: ['condition']
}
}
3 changes: 2 additions & 1 deletion src/js/types/schema/SnippetSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface WritableSnippetSchema {
scope?: SnippetScope
condition_id?: number
active?: boolean
locked?: boolean
priority?: number
network?: boolean | null
shared_network?: boolean | null
Expand All @@ -17,4 +18,4 @@ export interface SnippetSchema extends Readonly<Required<WritableSnippetSchema>>
readonly id: number
readonly modified: string
readonly code_error?: readonly [string, number] | null
}
}
4 changes: 3 additions & 1 deletion src/js/utils/snippets/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const mapToSchema = ({
scope,
priority,
active,
locked,
network,
shared_network,
conditionId
Expand All @@ -45,6 +46,7 @@ const mapToSchema = ({
scope,
priority,
active,
locked,
network,
shared_network,
condition_id: conditionId
Expand Down Expand Up @@ -89,4 +91,4 @@ export const buildSnippetsAPI = ({ get, post, del, put }: RestAPI): SnippetsAPI

detach: snippet =>
put(buildURL(snippet, 'detach'))
})
})
4 changes: 3 additions & 1 deletion src/js/utils/snippets/objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const defaults: Omit<Snippet, 'tags'> = {
scope: 'global',
modified: '',
active: false,
locked: false,
network: isNetworkAdmin(),
shared_network: null,
priority: 10,
Expand Down Expand Up @@ -43,9 +44,10 @@ export const parseSnippetObject = (fields: unknown): Snippet => {
...'scope' in fields && isValidScope(fields.scope) && { scope: fields.scope },
...'modified' in fields && 'string' === typeof fields.modified && { modified: fields.modified },
...'active' in fields && 'boolean' === typeof fields.active && { active: fields.active },
...'locked' in fields && 'boolean' === typeof fields.locked && { locked: fields.locked },
...'network' in fields && 'boolean' === typeof fields.network && { network: fields.network },
...'shared_network' in fields && 'boolean' === typeof fields.shared_network && { shared_network: fields.shared_network },
...'priority' in fields && 'number' === typeof fields.priority && { priority: fields.priority },
...'condition_id' in fields && isAbsInt(fields.condition_id) && { conditionId: fields.condition_id }
}
}
}
Loading